Table: Support display of multiple sub tables (#71953)

* Add nested option to DataFrame. Refactor Table to use nested dataframes for sub-tables

* Use nested frames for TraceQL response

* debugging

* Fix cell text and table position

* Update getItemSize

* noHeader size

* Update sub table renderer

* Update table container height

* Cleanup and fix RawPrometheusContainer height

* Update resultTransformer and docker script

* Updates to TableContainer, resultTransformer after merge

* Fixes for table pagination in dashboards

* Cell height and show footer enhancement/fix

* Sub table links

* Update RawPrometheusContainer

* Remove console log

* Update tests

* Update storybook

* Remove Tempo demo

* Store nested data in single field via its values

* Move nested prop into custom

* Tempo demo

* Add field type & update incorrect logic

* Update docker compose image for Tempo

* Update packages/grafana-data/src/field/fieldOverrides.ts

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>

* Simplify logic for getting nestedFrames and rendering sub tables

* Update docs for table

* Update nested table bg color

* Lighten nested table bg color

* Renames

* Migrate frames using parentRowIndex and add deprecation notice

* Update title

* Align expander icon size between Table and interactive table

* Table: Refactor out the expanded rows bits

* fix spacing

* Add line along left side for expanded rows

* Disable hover row background when expanded

---------

Co-authored-by: André Pereira <adrapereira@gmail.com>
Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
pull/73148/head
Joey 2 years ago committed by GitHub
parent 43aab615c3
commit 8c2f439cd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .betterer.results
  2. 2
      devenv/docker/blocks/tempo/docker-compose.yaml
  3. 45
      packages/grafana-data/src/field/fieldOverrides.test.ts
  4. 30
      packages/grafana-data/src/field/fieldOverrides.ts
  5. 1
      packages/grafana-data/src/types/dataFrame.ts
  6. 1
      packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx
  7. 86
      packages/grafana-ui/src/components/Table/ExpandedRow.tsx
  8. 2
      packages/grafana-ui/src/components/Table/RowExpander.tsx
  9. 15
      packages/grafana-ui/src/components/Table/Table.mdx
  10. 123
      packages/grafana-ui/src/components/Table/Table.story.tsx
  11. 76
      packages/grafana-ui/src/components/Table/Table.test.tsx
  12. 98
      packages/grafana-ui/src/components/Table/Table.tsx
  13. 26
      packages/grafana-ui/src/components/Table/hooks.ts
  14. 2
      packages/grafana-ui/src/components/Table/types.ts
  15. 4
      packages/grafana-ui/src/components/Table/utils.ts
  16. 24
      public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx
  17. 44
      public/app/features/explore/Table/TableContainer.tsx
  18. 46
      public/app/plugins/datasource/tempo/resultTransformer.test.ts
  19. 87
      public/app/plugins/datasource/tempo/resultTransformer.ts
  20. 150
      public/app/plugins/datasource/tempo/testResponse.ts
  21. 39
      public/app/plugins/datasource/tempo/types.ts
  22. 20
      public/app/plugins/panel/table/TablePanel.tsx
  23. 64
      public/app/plugins/panel/table/migrations.test.ts
  24. 43
      public/app/plugins/panel/table/migrations.ts

@ -1182,6 +1182,10 @@ exports[`better eslint`] = {
"packages/grafana-ui/src/components/Table/DefaultCell.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-ui/src/components/Table/ExpandedRow.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"packages/grafana-ui/src/components/Table/Filter.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

@ -72,7 +72,7 @@
labels: namespace
tempo:
image: grafana/tempo:main-dcf8a2a
image: grafana/tempo:latest
command:
- --config.file=/etc/tempo.yaml
volumes:

@ -204,6 +204,51 @@ describe('applyFieldOverrides', () => {
});
});
describe('given nested data frames', () => {
const f0Nested = createDataFrame({
name: 'nested',
fields: [{ name: 'info', type: FieldType.string, values: [10, 20] }],
});
const f0 = createDataFrame({
name: 'A',
fields: [
{
name: 'message',
type: FieldType.string,
values: [10, 20],
},
{
name: 'nested',
type: FieldType.nestedFrames,
values: [[f0Nested]],
},
],
});
it('should add scopedVars to fields', () => {
const withOverrides = applyFieldOverrides({
data: [f0],
fieldConfig: {
defaults: {},
overrides: [],
},
replaceVariables: (value) => value,
theme: createTheme(),
fieldConfigRegistry: new FieldConfigOptionsRegistry(),
});
expect(withOverrides[0].fields[1].values[0][0].fields[0].state!.scopedVars?.__dataContext?.value.frame).toBe(
withOverrides[0].fields[1].values[0][0]
);
expect(withOverrides[0].fields[1].values[0][0].fields[0].state!.scopedVars?.__dataContext?.value.frameIndex).toBe(
0
);
expect(withOverrides[0].fields[1].values[0][0].fields[0].state!.scopedVars?.__dataContext?.value.field).toBe(
withOverrides[0].fields[1].values[0][0].fields[0]
);
});
});
it('will merge FieldConfig with default values', () => {
const field: FieldConfig = {
min: 0,

@ -207,6 +207,36 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
options.timeZone,
options.dataLinkPostProcessor
);
if (field.type === FieldType.nestedFrames) {
for (const nestedFrames of field.values) {
for (let nfIndex = 0; nfIndex < nestedFrames.length; nfIndex++) {
for (const valueField of nestedFrames[nfIndex].fields) {
valueField.state = {
scopedVars: {
__dataContext: {
value: {
data: nestedFrames,
frame: nestedFrames[nfIndex],
frameIndex: nfIndex,
field: valueField,
},
},
},
};
valueField.getLinks = getLinksSupplier(
nestedFrames[nfIndex],
valueField,
valueField.state!.scopedVars,
context.replaceVariables,
options.timeZone,
options.dataLinkPostProcessor
);
}
}
}
}
}
return newFrame;

@ -19,6 +19,7 @@ export enum FieldType {
enum = 'enum',
other = 'other', // Object, Array, etc
frame = 'frame', // DataFrame
nestedFrames = 'nestedFrames', // @alpha Nested DataFrames
}
/**

@ -22,6 +22,7 @@ export function ExpanderCell<K extends object>({ row, __rowID }: CellProps<K, vo
aria-expanded={row.isExpanded}
// @ts-expect-error same as the line above
{...row.getToggleRowExpandedProps()}
size="lg"
/>
</div>
);

@ -0,0 +1,86 @@
import { css } from '@emotion/css';
import React, { CSSProperties } from 'react';
import { DataFrame, Field, GrafanaTheme2 } from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';
import { useStyles2, useTheme2 } from '../../themes';
import { Table } from './Table';
import { TableStyles } from './styles';
import { EXPANDER_WIDTH } from './utils';
export interface Props {
nestedData: Field;
tableStyles: TableStyles;
rowIndex: number;
width: number;
cellHeight: TableCellHeight;
}
export function ExpandedRow({ tableStyles, nestedData, rowIndex, width, cellHeight }: Props) {
const frames = nestedData.values as DataFrame[][];
const subTables: React.ReactNode[] = [];
const theme = useTheme2();
const styles = useStyles2(getStyles);
let top = tableStyles.rowHeight + theme.spacing.gridSize; // initial height for row that expands above sub tables + 1 grid unit spacing
frames[rowIndex].forEach((nf: DataFrame, nfIndex: number) => {
const noHeader = !!nf.meta?.custom?.noHeader;
const height = tableStyles.rowHeight * (nf.length + (noHeader ? 0 : 1)); // account for the header with + 1
const subTable: CSSProperties = {
height: height,
paddingLeft: EXPANDER_WIDTH,
position: 'absolute',
top,
};
top += height + theme.spacing.gridSize;
subTables.push(
<div style={subTable} key={`subTable_${rowIndex}_${nfIndex}`}>
<Table
data={nf}
width={width - EXPANDER_WIDTH}
height={tableStyles.rowHeight * (nf.length + 1)}
noHeader={noHeader}
cellHeight={cellHeight}
/>
</div>
);
});
return <div className={styles.subTables}>{subTables}</div>;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
subTables: css({
'&:before': {
content: '""',
position: 'absolute',
width: '1px',
top: theme.spacing(5),
left: theme.spacing(1),
bottom: theme.spacing(2),
background: theme.colors.border.medium,
},
}),
};
};
export function getExpandedRowHeight(nestedData: Field, rowIndex: number, tableStyles: TableStyles) {
const frames = nestedData.values as DataFrame[][];
const height = frames[rowIndex].reduce((acc: number, frame: DataFrame) => {
if (frame.length) {
const noHeader = !!frame.meta?.custom?.noHeader;
return acc + tableStyles.rowHeight * (frame.length + (noHeader ? 0 : 1)) + 8; // account for the header with + 1
}
return acc;
}, tableStyles.rowHeight); // initial height for row that expands above sub tables
return height ?? tableStyles.rowHeight;
}

@ -16,7 +16,7 @@ export function RowExpander({ row, tableStyles }: Props) {
<Icon
aria-label={row.isExpanded ? 'Collapse row' : 'Expand row'}
name={row.isExpanded ? 'angle-down' : 'angle-right'}
size="xl"
size="lg"
/>
</div>
);

@ -7,11 +7,18 @@ 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`
Sub-tables are supported by adding `FieldType.nestedFrames` to the field that contains the nested data in your dataframe.
- **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
This nested fields values can contain an array of one or more dataframes. Each of these dataframes will be rendered as one unique sub-table.
For each dataframe and index in the nested field, the dataframe will be rendered as one or more sub-tables below the main dataframe row at that index.
Each dataframe also supports using the following custom property under `dataframe.meta.custom`:
- **noHeader**: boolean - Hides that sub-tables header.
- @deprecated use `FieldType.nestedFrames` instead
- **parentRowIndex**: number - The index of the parent row in the main dataframe (under the `data` prop of the Table component).
## Cell rendering

@ -42,7 +42,7 @@ const meta: Meta<typeof Table> = {
},
};
function buildData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): DataFrame {
function buildData(theme: GrafanaTheme2, config: Record<string, FieldConfig>, rows = 1000): DataFrame {
const data = new MutableDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [] }, // The time field
@ -87,7 +87,7 @@ function buildData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): D
field.config = merge(field.config, config[field.name]);
}
for (let i = 0; i < 1000; i++) {
for (let i = 0; i < rows; i++) {
data.appendRow([
new Date().getTime(),
Math.random() * 2,
@ -100,59 +100,72 @@ 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',
function buildSubTablesData(theme: GrafanaTheme2, config: Record<string, FieldConfig>, rows: number): DataFrame {
const data = buildData(theme, {}, rows);
const allNestedFrames: DataFrame[][] = [];
for (let i = 0; i < rows; i++) {
const nestedFrames: DataFrame[] = [];
for (let i = 0; i < Math.random() * 3; i++) {
const nestedData = new MutableDataFrame({
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,
{ 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,
]);
],
});
for (const field of nestedData.fields) {
field.config = merge(field.config, config[field.name]);
}
for (let i = 0; i < Math.random() * 4; i++) {
nestedData.appendRow([
new Date().getTime(),
Math.random() * 2,
Math.random() > 0.7 ? 'Good' : 'Bad',
Math.random() * 100,
]);
}
nestedFrames.push(nestedData);
}
frames.push(data);
allNestedFrames.push(prepDataForStorybook(nestedFrames, theme));
}
return prepDataForStorybook(frames, theme);
data.fields = [
...data.fields,
{
name: 'nested',
type: FieldType.nestedFrames,
values: allNestedFrames,
config: {},
},
];
return data;
}
function buildFooterData(data: DataFrame): FooterItem[] {
@ -257,19 +270,11 @@ Pagination.args = {
export const SubTables: StoryFn<typeof Table> = (args) => {
const theme = useTheme2();
const data = buildData(theme, {});
const subData = buildSubTablesData(theme, {
Progress: {
custom: {
displayMode: 'gradient-gauge',
},
thresholds: defaultThresholds,
},
});
const data = buildSubTablesData(theme, {}, 100);
return (
<DashboardStoryCanvas>
<Table {...args} data={data} subData={subData} />
<Table {...args} data={data} />
</DashboardStoryCanvas>
);
};

@ -58,6 +58,10 @@ function getDefaultDataFrame(): DataFrame {
},
],
});
return applyOverrides(dataFrame);
}
function applyOverrides(dataFrame: DataFrame) {
const dataFrames = applyFieldOverrides({
data: [dataFrame],
fieldConfig: {
@ -515,31 +519,41 @@ describe('Table', () => {
});
});
describe('when mounted with data and sub-data', () => {
describe('when mounted with nested data', () => {
it('then correct rows should be rendered and new table is rendered when expander is clicked', async () => {
getTestContext({
subData: new Array(getDefaultDataFrame().length).fill(0).map((i) =>
const nestedFrame = (idx: number) =>
applyOverrides(
toDataFrame({
name: 'A',
name: `nested_frame${idx}`,
fields: [
{
name: 'number' + i,
type: FieldType.number,
values: [i, i, i],
config: {
custom: {
filterable: true,
},
},
name: `humidity_${idx}`,
type: FieldType.string,
values: [`3%_${idx}`, `17%_${idx}`],
},
],
meta: {
custom: {
parentRowIndex: i,
{
name: `status_${idx}`,
type: FieldType.string,
values: [`ok_${idx}`, `humid_${idx}`],
},
},
],
})
),
);
const defaultFrame = getDefaultDataFrame();
getTestContext({
data: applyOverrides({
...defaultFrame,
fields: [
...defaultFrame.fields,
{
name: 'nested',
type: FieldType.nestedFrames,
values: [[nestedFrame(0), nestedFrame(1)]],
config: {},
},
],
}),
});
expect(getTable()).toBeInTheDocument();
expect(screen.getAllByRole('columnheader')).toHaveLength(4);
@ -557,11 +571,27 @@ describe('Table', () => {
]);
await userEvent.click(within(rows[1]).getByLabelText('Expand row'));
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();
expect(screen.getAllByRole('columnheader')).toHaveLength(8);
expect(getColumnHeader(/humidity_0/)).toBeInTheDocument();
expect(getColumnHeader(/humidity_1/)).toBeInTheDocument();
expect(getColumnHeader(/status_0/)).toBeInTheDocument();
expect(getColumnHeader(/status_1/)).toBeInTheDocument();
const subTable0 = screen.getAllByRole('table')[1];
const subTableRows0 = within(subTable0).getAllByRole('row');
expect(subTableRows0).toHaveLength(3);
expect(within(subTableRows0[1]).getByText(/3%_0/)).toBeInTheDocument();
expect(within(subTableRows0[1]).getByText(/ok_0/)).toBeInTheDocument();
expect(within(subTableRows0[2]).getByText(/17%_0/)).toBeInTheDocument();
expect(within(subTableRows0[2]).getByText(/humid_0/)).toBeInTheDocument();
const subTable1 = screen.getAllByRole('table')[2];
const subTableRows1 = within(subTable1).getAllByRole('row');
expect(subTableRows1).toHaveLength(3);
expect(within(subTableRows1[1]).getByText(/3%_1/)).toBeInTheDocument();
expect(within(subTableRows1[1]).getByText(/ok_1/)).toBeInTheDocument();
expect(within(subTableRows1[2]).getByText(/17%_1/)).toBeInTheDocument();
expect(within(subTableRows1[2]).getByText(/humid_1/)).toBeInTheDocument();
});
});
});

@ -1,3 +1,4 @@
import { css, cx } from '@emotion/css';
import React, { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, UIEventHandler } from 'react';
import {
Cell,
@ -11,13 +12,14 @@ import {
} from 'react-table';
import { VariableSizeList } from 'react-window';
import { Field, ReducerID } from '@grafana/data';
import { Field, FieldType, ReducerID } from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';
import { useTheme2 } from '../../themes';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { Pagination } from '../Pagination/Pagination';
import { getExpandedRowHeight, ExpandedRow } from './ExpandedRow';
import { FooterRow } from './FooterRow';
import { HeaderRow } from './HeaderRow';
import { TableCell } from './TableCell';
@ -25,14 +27,7 @@ import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks
import { getInitialState, useTableStateReducer } from './reducer';
import { useTableStyles } from './styles';
import { FooterItem, GrafanaTableState, Props } from './types';
import {
getColumns,
sortCaseInsensitive,
sortNumber,
getFooterItems,
createFooterCalculationValues,
EXPANDER_WIDTH,
} from './utils';
import { getColumns, sortCaseInsensitive, sortNumber, getFooterItems, createFooterCalculationValues } from './utils';
const COLUMN_MIN_WIDTH = 150;
const FOOTER_ROW_HEIGHT = 36;
@ -41,7 +36,6 @@ export const Table = memo((props: Props) => {
const {
ariaLabel,
data,
subData,
height,
onCellFilterAdded,
width,
@ -106,10 +100,13 @@ export const Table = memo((props: Props) => {
footerOptions.reducer[0] === ReducerID.count
);
const nestedDataField = data.fields.find((f) => f.type === FieldType.nestedFrames);
const hasNestedData = nestedDataField !== undefined;
// React-table column definitions
const memoizedColumns = useMemo(
() => getColumns(data, width, columnMinWidth, !!subData?.length, footerItems, isCountRowsSet),
[data, width, columnMinWidth, footerItems, subData, isCountRowsSet]
() => getColumns(data, width, columnMinWidth, hasNestedData, footerItems, isCountRowsSet),
[data, width, columnMinWidth, footerItems, hasNestedData, isCountRowsSet]
);
// Internal react table state reducer
@ -207,35 +204,11 @@ export const Table = memo((props: Props) => {
useResetVariableListSizeCache(extendedState, listRef, data);
useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef);
const renderSubTable = useCallback(
(rowIndex: number) => {
if (state.expanded[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;
const rowIndexForPagination = useCallback(
(index: number) => {
return state.pageIndex * state.pageSize + index;
},
[state.expanded, subData, tableStyles.rowHeight, theme.colors, width]
[state.pageIndex, state.pageSize]
);
const RenderRow = useCallback(
@ -247,10 +220,20 @@ export const Table = memo((props: Props) => {
prepareRow(row);
const expandedRowStyle = state.expanded[row.index] ? css({ '&:hover': { background: 'inherit' } }) : {};
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)}
<div {...row.getRowProps({ style })} className={cx(tableStyles.row, expandedRowStyle)}>
{/*add the nested data to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/}
{nestedDataField && state.expanded[row.index] && (
<ExpandedRow
nestedData={nestedDataField}
tableStyles={tableStyles}
rowIndex={row.index}
width={width}
cellHeight={cellHeight}
/>
)}
{row.cells.map((cell: Cell, index: number) => (
<TableCell
key={index}
@ -266,7 +249,20 @@ export const Table = memo((props: Props) => {
</div>
);
},
[onCellFilterAdded, page, enablePagination, prepareRow, rows, tableStyles, renderSubTable, timeRange, data]
[
rows,
enablePagination,
prepareRow,
tableStyles,
nestedDataField,
page,
onCellFilterAdded,
timeRange,
data,
width,
cellHeight,
state.expanded,
]
);
const onNavigate = useCallback(
@ -303,13 +299,11 @@ export const Table = memo((props: Props) => {
}
const getItemSize = (index: number): number => {
if (state.expanded[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
}
const indexForPagination = rowIndexForPagination(index);
if (state.expanded[indexForPagination] && nestedDataField) {
return getExpandedRowHeight(nestedDataField, indexForPagination, tableStyles);
}
return tableStyles.rowHeight;
};
@ -339,8 +333,8 @@ export const Table = memo((props: Props) => {
<div ref={variableSizeListScrollbarRef}>
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true}>
<VariableSizeList
// This component needs an unmount/remount when row height changes
key={tableStyles.rowHeight}
// This component needs an unmount/remount when row height or page changes
key={tableStyles.rowHeight + state.pageIndex}
height={listHeight}
itemCount={itemCount}
itemSize={getItemSize}

@ -46,8 +46,30 @@ export function useResetVariableListSizeCache(
) {
useEffect(() => {
if (extendedState.lastExpandedIndex !== undefined) {
listRef.current?.resetAfterIndex(Math.max(extendedState.lastExpandedIndex - 1, 0));
// Gets the expanded row with the lowest index. Needed to reset all expanded row heights from that index on
let resetIndex = extendedState.lastExpandedIndex;
const expandedIndexes = Object.keys(extendedState.expanded);
if (expandedIndexes.length > 0) {
const lowestExpandedIndex = parseInt(expandedIndexes[0], 10);
if (!isNaN(lowestExpandedIndex)) {
resetIndex = Math.min(resetIndex, lowestExpandedIndex);
}
}
const index =
extendedState.pageIndex === 0
? resetIndex - 1
: resetIndex - extendedState.pageIndex - extendedState.pageIndex * extendedState.pageSize;
listRef.current?.resetAfterIndex(Math.max(index, 0));
return;
}
}, [extendedState.lastExpandedIndex, extendedState.toggleRowExpandedCounter, listRef, data]);
}, [
extendedState.lastExpandedIndex,
extendedState.toggleRowExpandedCounter,
extendedState.pageIndex,
extendedState.pageSize,
listRef,
data,
extendedState.expanded,
]);
}

@ -81,8 +81,6 @@ export interface Props {
footerValues?: FooterItem[];
enablePagination?: boolean;
cellHeight?: schema.TableCellHeight;
/** @alpha */
subData?: DataFrame[];
/** @alpha Used by SparklineCell when provided */
timeRange?: TimeRange;
}

@ -102,7 +102,7 @@ export function getColumns(
for (const [fieldIndex, field] of data.fields.entries()) {
const fieldTableOptions = (field.config.custom || {}) as TableFieldOptions;
if (fieldTableOptions.hidden) {
if (fieldTableOptions.hidden || field.type === FieldType.nestedFrames) {
continue;
}
@ -476,7 +476,7 @@ function addMissingColumnIndex(columns: Array<{ id: string; field?: Field } | un
const missingIndex = columns.findIndex((field, index) => field?.id !== String(index));
// Base case
if (missingIndex === -1) {
if (missingIndex === -1 || columns[missingIndex]?.id === 'expander') {
return;
}

@ -57,24 +57,19 @@ export class RawPrometheusContainer extends PureComponent<Props, PrometheusConta
}
}
getMainFrame(frames: DataFrame[] | null) {
return frames?.find((df) => df.meta?.custom?.parentRowIndex === undefined) || frames?.[0];
}
onChangeResultsStyle = (resultsStyle: TableResultsStyle) => {
this.setState({ resultsStyle });
};
getTableHeight() {
const { tableResult } = this.props;
const mainFrame = this.getMainFrame(tableResult);
if (!mainFrame || mainFrame.length === 0) {
if (!tableResult || tableResult.length === 0) {
return 200;
}
// tries to estimate table height
return Math.max(Math.min(600, mainFrame.length * 35) + 35);
return Math.max(Math.min(600, tableResult[0].length * 35) + 35);
}
renderLabel = () => {
@ -134,8 +129,10 @@ export class RawPrometheusContainer extends PureComponent<Props, PrometheusConta
});
}
const mainFrame = this.getMainFrame(dataFrames);
const subFrames = dataFrames?.filter((df) => df.meta?.custom?.parentRowIndex !== undefined);
const frames = dataFrames?.filter(
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
);
const label = this.state?.resultsStyle !== undefined ? this.renderLabel() : 'Table';
// Render table as default if resultsStyle is not set.
@ -143,22 +140,21 @@ export class RawPrometheusContainer extends PureComponent<Props, PrometheusConta
return (
<Collapse label={label} loading={loading} isOpen>
{mainFrame?.length && (
{frames?.length && (
<>
{renderTable && (
<Table
ariaLabel={ariaLabel}
data={mainFrame}
subData={subFrames}
data={frames[0]}
width={tableWidth}
height={height}
onCellFilterAdded={onCellFilterAdded}
/>
)}
{this.state?.resultsStyle === TABLE_RESULTS_STYLE.raw && <RawListContainer tableResult={mainFrame} />}
{this.state?.resultsStyle === TABLE_RESULTS_STYLE.raw && <RawListContainer tableResult={frames[0]} />}
</>
)}
{!mainFrame?.length && <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
{!frames?.length && <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
</Collapse>
);
}

@ -1,9 +1,13 @@
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { applyFieldOverrides, TimeZone, SplitOpen, DataFrame, LoadingState } from '@grafana/data';
import { applyFieldOverrides, TimeZone, SplitOpen, DataFrame, LoadingState, FieldType } from '@grafana/data';
import { Table, AdHocFilterItem, PanelChrome } from '@grafana/ui';
import { config } from 'app/core/config';
import {
hasDeprecatedParentRowIndex,
migrateFromParentRowIndexToNestedFrames,
} from 'app/plugins/panel/table/migrations';
import { StoreState } from 'app/types';
import { ExploreItemState } from 'app/types/explore';
@ -34,11 +38,9 @@ const connector = connect(mapStateToProps, {});
type Props = TableContainerProps & ConnectedProps<typeof connector>;
export class TableContainer extends PureComponent<Props> {
getMainFrames(frames: DataFrame[] | null) {
return frames?.filter((df) => df.meta?.custom?.parentRowIndex === undefined) || [frames?.[0]];
}
hasSubFrames = (data: DataFrame) => data.fields.some((f) => f.type === FieldType.nestedFrames);
getTableHeight(rowCount: number, hasSubFrames = true) {
getTableHeight(rowCount: number, hasSubFrames: boolean) {
if (rowCount === 0) {
return 200;
}
@ -50,8 +52,9 @@ export class TableContainer extends PureComponent<Props> {
render() {
const { loading, onCellFilterAdded, tableResult, width, splitOpenFn, range, ariaLabel, timeZone } = this.props;
let dataFrames = tableResult;
let dataFrames = hasDeprecatedParentRowIndex(tableResult)
? migrateFromParentRowIndexToNestedFrames(tableResult)
: tableResult;
const dataLinkPostProcessor = exploreDataLinkPostProcessorFactory(splitOpenFn, range);
if (dataFrames?.length) {
@ -68,40 +71,31 @@ export class TableContainer extends PureComponent<Props> {
});
}
// move dataframes to be grouped by table, with optional sub-tables for a row
const tableData: Array<{ main: DataFrame; sub?: DataFrame[] }> = [];
const mainFrames = this.getMainFrames(dataFrames).filter(
const frames = dataFrames?.filter(
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
);
mainFrames?.forEach((frame) => {
const subFrames =
dataFrames?.filter((df) => frame.refId === df.refId && df.meta?.custom?.parentRowIndex !== undefined) ||
undefined;
tableData.push({ main: frame, sub: subFrames });
});
return (
<>
{tableData.length === 0 && (
{frames && frames.length === 0 && (
<PanelChrome title={'Table'} width={width} height={200}>
{() => <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
</PanelChrome>
)}
{tableData.length > 0 &&
tableData.map((data, i) => (
{frames &&
frames.length > 0 &&
frames.map((data, i) => (
<PanelChrome
key={data.main.refId || `table-${i}`}
title={tableData.length > 1 ? `Table - ${data.main.name || data.main.refId || i}` : 'Table'}
key={data.refId || `table-${i}`}
title={dataFrames && dataFrames.length > 1 ? `Table - ${data.name || data.refId || i}` : 'Table'}
width={width}
height={this.getTableHeight(data.main.length, (data.sub?.length || 0) > 0)}
height={this.getTableHeight(data.length, this.hasSubFrames(data))}
loadingState={loading ? LoadingState.Loading : undefined}
>
{(innerWidth, innerHeight) => (
<Table
ariaLabel={ariaLabel}
data={data.main}
subData={data.sub}
data={data}
width={innerWidth}
height={innerHeight}
onCellFilterAdded={onCellFilterAdded}

@ -142,6 +142,52 @@ describe('createTableFrameFromTraceQlQuery()', () => {
expect(frame.fields[3].name).toBe('traceDuration');
expect(frame.fields[3].type).toBe('number');
expect(frame.fields[3].values[2]).toBe(44);
// Subframes field
expect(frame.fields[4].name).toBe('nested');
expect(frame.fields[4].type).toBe('nestedFrames');
// Single spanset
expect(frame.fields[4].values[0][0].fields[0].name).toBe('traceIdHidden');
expect(frame.fields[4].values[0][0].fields[0].values[0]).toBe('b1586c3c8c34d');
expect(frame.fields[4].values[0][0].fields[1].name).toBe('spanID');
expect(frame.fields[4].values[0][0].fields[1].values[0]).toBe('162a4adae63b61f1');
expect(frame.fields[4].values[0][0].fields[2].name).toBe('spanStartTime');
expect(frame.fields[4].values[0][0].fields[2].values[0]).toBe('2022-10-19 09:03:34');
expect(frame.fields[4].values[0][0].fields[4].name).toBe('http.method');
expect(frame.fields[4].values[0][0].fields[4].values[0]).toBe('GET');
expect(frame.fields[4].values[0][0].fields[5].name).toBe('service.name');
expect(frame.fields[4].values[0][0].fields[5].values[0]).toBe('db');
expect(frame.fields[4].values[0][0].fields[6].name).toBe('duration');
expect(frame.fields[4].values[0][0].fields[6].values[0]).toBe(545000);
// Multiple spansets - set 0
expect(frame.fields[4].values[1][0].fields[0].name).toBe('traceIdHidden');
expect(frame.fields[4].values[1][0].fields[0].values[0]).toBe('9161e77388f3e');
expect(frame.fields[4].values[1][0].fields[1].name).toBe('spanID');
expect(frame.fields[4].values[1][0].fields[1].values[0]).toBe('3b9a5c222d3ddd8f');
expect(frame.fields[4].values[1][0].fields[2].name).toBe('spanStartTime');
expect(frame.fields[4].values[1][0].fields[2].values[0]).toBe('2022-10-19 08:57:55');
expect(frame.fields[4].values[1][0].fields[4].name).toBe('by(resource.service.name)');
expect(frame.fields[4].values[1][0].fields[4].values[0]).toBe('db');
expect(frame.fields[4].values[1][0].fields[5].name).toBe('http.method');
expect(frame.fields[4].values[1][0].fields[5].values[0]).toBe('GET');
expect(frame.fields[4].values[1][0].fields[6].name).toBe('service.name');
expect(frame.fields[4].values[1][0].fields[6].values[0]).toBe('db');
expect(frame.fields[4].values[1][0].fields[7].name).toBe('duration');
expect(frame.fields[4].values[1][0].fields[7].values[0]).toBe(877000);
// Multiple spansets - set 1
expect(frame.fields[4].values[1][1].fields[0].name).toBe('traceIdHidden');
expect(frame.fields[4].values[1][1].fields[0].values[0]).toBe('9161e77388f3e');
expect(frame.fields[4].values[1][1].fields[1].name).toBe('spanID');
expect(frame.fields[4].values[1][1].fields[1].values[0]).toBe('894d90db6b5807f');
expect(frame.fields[4].values[1][1].fields[2].name).toBe('spanStartTime');
expect(frame.fields[4].values[1][1].fields[2].values[0]).toBe('2022-10-19 08:57:55');
expect(frame.fields[4].values[1][1].fields[4].name).toBe('by(resource.service.name)');
expect(frame.fields[4].values[1][1].fields[4].values[0]).toBe('app');
expect(frame.fields[4].values[1][1].fields[5].name).toBe('http.method');
expect(frame.fields[4].values[1][1].fields[5].values[0]).toBe('GET');
expect(frame.fields[4].values[1][1].fields[6].name).toBe('service.name');
expect(frame.fields[4].values[1][1].fields[6].values[0]).toBe('app');
expect(frame.fields[4].values[1][1].fields[7].name).toBe('duration');
expect(frame.fields[4].values[1][1].fields[7].values[0]).toBe(11073000);
});
});

@ -14,10 +14,13 @@ import {
TraceSpanRow,
dateTimeFormat,
FieldDTO,
createDataFrame,
getDisplayProcessor,
createTheme,
} from '@grafana/data';
import { createGraphFrames } from './graphTransform';
import { Span, TraceSearchMetadata } from './types';
import { Span, SpanAttributes, Spanset, TraceSearchMetadata } from './types';
export function createTableFrame(
logsFrame: DataFrame,
@ -565,7 +568,7 @@ function transformToTraceData(data: TraceSearchMetadata) {
return {
traceID: data.traceID,
startTime,
traceDuration: data.durationMs?.toString(),
traceDuration: data.durationMs,
traceName,
};
}
@ -574,7 +577,7 @@ export function createTableFrameFromTraceQlQuery(
data: TraceSearchMetadata[],
instanceSettings: DataSourceInstanceSettings
): DataFrame[] {
const frame = new MutableDataFrame({
const frame = createDataFrame({
name: 'Traces',
fields: [
{
@ -624,6 +627,10 @@ export function createTableFrameFromTraceQlQuery(
},
},
},
{
name: 'nested',
type: FieldType.nestedFrames,
},
],
meta: {
preferredVisualisationType: 'table',
@ -633,33 +640,48 @@ export function createTableFrameFromTraceQlQuery(
if (!data?.length) {
return [frame];
}
frame.length = data.length;
const subDataFrames: DataFrame[] = [];
const tableRows = data
data
// Show the most recent traces
.sort((a, b) => parseInt(b?.startTimeUnixNano!, 10) / 1000000 - parseInt(a?.startTimeUnixNano!, 10) / 1000000)
.reduce((rows: TraceTableData[], trace, currentIndex) => {
.forEach((trace) => {
const traceData: TraceTableData = transformToTraceData(trace);
rows.push(traceData);
subDataFrames.push(traceSubFrame(trace, instanceSettings, currentIndex));
return rows;
}, []);
for (const row of tableRows) {
frame.add(row);
}
frame.fields[0].values.push(traceData.traceID);
frame.fields[1].values.push(traceData.startTime);
frame.fields[2].values.push(traceData.traceName);
frame.fields[3].values.push(traceData.traceDuration);
if (trace.spanSets) {
frame.fields[4].values.push(
trace.spanSets.map((spanSet: Spanset) => {
return traceSubFrame(trace, spanSet, instanceSettings);
})
);
} else if (trace.spanSet) {
frame.fields[4].values.push([traceSubFrame(trace, trace.spanSet, instanceSettings)]);
}
});
return [frame, ...subDataFrames];
return [frame];
}
const traceSubFrame = (
trace: TraceSearchMetadata,
instanceSettings: DataSourceInstanceSettings,
currentIndex: number
spanSet: Spanset,
instanceSettings: DataSourceInstanceSettings
): DataFrame => {
const spanDynamicAttrs: Record<string, FieldDTO> = {};
let hasNameAttribute = false;
trace.spanSet?.spans.forEach((span) => {
spanSet.attributes?.map((attr) => {
spanDynamicAttrs[attr.key] = {
name: attr.key,
type: FieldType.string,
config: { displayNameFromDS: attr.key },
};
});
spanSet.spans.forEach((span) => {
if (span.name) {
hasNameAttribute = true;
}
@ -671,6 +693,7 @@ const traceSubFrame = (
};
});
});
const subFrame = new MutableDataFrame({
fields: [
{
@ -724,7 +747,7 @@ const traceSubFrame = (
type: FieldType.string,
config: { displayNameFromDS: 'Name', custom: { hidden: !hasNameAttribute } },
},
...Object.values(spanDynamicAttrs),
...Object.values(spanDynamicAttrs).sort((a, b) => a.name.localeCompare(b.name)),
{
name: 'duration',
type: FieldType.number,
@ -739,14 +762,16 @@ const traceSubFrame = (
],
meta: {
preferredVisualisationType: 'table',
custom: {
parentRowIndex: currentIndex,
},
},
});
trace.spanSet?.spans.forEach((span) => {
subFrame.add(transformSpanToTraceData(span, trace.traceID));
const theme = createTheme();
for (const field of subFrame.fields) {
field.display = getDisplayProcessor({ field, theme });
}
spanSet.spans.forEach((span) => {
subFrame.add(transformSpanToTraceData(span, spanSet, trace.traceID));
});
return subFrame;
@ -758,10 +783,10 @@ interface TraceTableData {
spanID?: string;
startTime?: string;
name?: string;
traceDuration?: string;
traceDuration?: number;
}
function transformSpanToTraceData(span: Span, traceID: string): TraceTableData {
function transformSpanToTraceData(span: Span, spanSet: Spanset, traceID: string): TraceTableData {
const spanStartTimeUnixMs = parseInt(span.startTimeUnixNano, 10) / 1000000;
let spanStartTime = dateTimeFormat(spanStartTimeUnixMs);
@ -773,7 +798,15 @@ function transformSpanToTraceData(span: Span, traceID: string): TraceTableData {
name: span.name,
};
span.attributes?.forEach((attr) => {
let attrs: SpanAttributes[] = [];
if (spanSet.attributes) {
attrs = attrs.concat(spanSet.attributes);
}
if (span.attributes) {
attrs = attrs.concat(span.attributes);
}
attrs.forEach((attr) => {
if (attr.value.boolValue || attr.value.Value?.bool_value) {
data[attr.key] = attr.value.boolValue || attr.value.Value?.bool_value;
}

@ -2391,87 +2391,91 @@ export const traceQlResponse = {
rootTraceName: 'HTTP Client',
startTimeUnixNano: '1643342166678000000',
durationMs: 93,
spanSet: {
spans: [
{
spanID: '3b9a5c222d3ddd8f',
startTimeUnixNano: '1666187875397721000',
durationNanos: '877000',
attributes: [
{
key: 'http.method',
value: {
stringValue: 'GET',
},
},
{
key: 'service.name',
value: {
stringValue: 'db',
},
spanSets: [
{
attributes: [
{
key: 'by(resource.service.name)',
value: {
stringValue: 'db',
},
],
},
{
spanID: '894d90db6b5807f',
startTimeUnixNano: '1666187875393293000',
durationNanos: '11073000',
attributes: [
{
key: 'http.method',
value: {
stringValue: 'GET',
},
],
spans: [
{
spanID: '3b9a5c222d3ddd8f',
startTimeUnixNano: '1666187875397721000',
durationNanos: '877000',
attributes: [
{
key: 'http.method',
value: {
stringValue: 'GET',
},
},
},
{
key: 'service.name',
value: {
stringValue: 'app',
{
key: 'service.name',
value: {
stringValue: 'db',
},
},
],
},
],
matched: 1,
},
{
attributes: [
{
key: 'by(resource.service.name)',
value: {
stringValue: 'app',
},
],
},
{
spanID: 'd3284e9c5081aab',
startTimeUnixNano: '1666187875393897000',
durationNanos: '10133000',
attributes: [
{
key: 'service.name',
value: {
stringValue: 'app',
},
],
spans: [
{
spanID: '894d90db6b5807f',
startTimeUnixNano: '1666187875393293000',
durationNanos: '11073000',
attributes: [
{
key: 'http.method',
value: {
stringValue: 'GET',
},
},
},
{
key: 'http.method',
value: {
stringValue: 'GET',
{
key: 'service.name',
value: {
stringValue: 'app',
},
},
},
],
},
{
spanID: '454785498fc8b1aa',
startTimeUnixNano: '1666187875389957000',
durationNanos: '13953000',
attributes: [
{
key: 'http.method',
value: {
stringValue: 'GET',
],
},
{
spanID: 'd3284e9c5081aab',
startTimeUnixNano: '1666187875393897000',
durationNanos: '10133000',
attributes: [
{
key: 'service.name',
value: {
stringValue: 'app',
},
},
},
{
key: 'service.name',
value: {
stringValue: 'lb',
{
key: 'http.method',
value: {
stringValue: 'GET',
},
},
},
],
},
],
matched: 4,
},
],
},
],
matched: 2,
},
],
},
{
traceID: '480691f7c6f20',

@ -1,4 +1,4 @@
import { DataSourceJsonData, KeyValue } from '@grafana/data/src';
import { DataSourceJsonData } from '@grafana/data/src';
import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings';
import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings';
@ -55,7 +55,8 @@ export type TraceSearchMetadata = {
rootTraceName: string;
startTimeUnixNano?: string;
durationMs?: number;
spanSet?: { spans: Span[] };
spanSet?: Spanset;
spanSets?: Spanset[];
};
export type SearchMetrics = {
@ -76,6 +77,22 @@ export enum SpanKind {
CONSUMER,
}
export type SpanAttributes = {
key: string;
value: {
stringValue?: string;
intValue?: string;
boolValue?: boolean;
doubleValue?: string;
Value?: {
string_value?: string;
int_value?: string;
bool_value?: boolean;
double_value?: string;
};
};
};
export type Span = {
durationNanos: string;
traceId?: string;
@ -86,26 +103,12 @@ export type Span = {
kind?: SpanKind;
startTimeUnixNano: string;
endTimeUnixNano?: string;
attributes?: Array<{
key: string;
value: {
stringValue?: string;
intValue?: string;
boolValue?: boolean;
doubleValue?: string;
Value?: {
string_value?: string;
int_value?: string;
bool_value?: boolean;
double_value?: string;
};
};
}>;
attributes?: SpanAttributes[];
dropped_attributes_count?: number;
};
export type Spanset = {
attributes: KeyValue[];
attributes?: SpanAttributes[];
spans: Span[];
};

@ -6,6 +6,7 @@ import { PanelDataErrorView } from '@grafana/runtime';
import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui';
import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
import { hasDeprecatedParentRowIndex, migrateFromParentRowIndexToNestedFrames } from './migrations';
import { Options } from './panelcfg.gen';
interface Props extends PanelProps<Options> {}
@ -15,16 +16,15 @@ export function TablePanel(props: Props) {
const theme = useTheme2();
const panelContext = usePanelContext();
const frames = data.series;
const mainFrames = frames.filter((f) => f.meta?.custom?.parentRowIndex === undefined);
const subFrames = frames.filter((f) => f.meta?.custom?.parentRowIndex !== undefined);
const count = mainFrames?.length;
const hasFields = mainFrames[0]?.fields.length;
const currentIndex = getCurrentFrameIndex(mainFrames, options);
const main = mainFrames[currentIndex];
const frames = hasDeprecatedParentRowIndex(data.series)
? migrateFromParentRowIndexToNestedFrames(data.series)
: data.series;
const count = frames?.length;
const hasFields = frames[0]?.fields.length;
const currentIndex = getCurrentFrameIndex(frames, options);
const main = frames[currentIndex];
let tableHeight = height;
let subData = subFrames;
if (!count || !hasFields) {
return <PanelDataErrorView panelId={id} fieldConfig={fieldConfig} data={data} />;
@ -35,7 +35,6 @@ export function TablePanel(props: Props) {
const padding = theme.spacing.gridSize;
tableHeight = height - inputHeight - padding;
subData = subFrames.filter((f) => f.refId === main.refId);
}
const tableElement = (
@ -52,7 +51,6 @@ export function TablePanel(props: Props) {
onCellFilterAdded={panelContext.onAddAdHocFilter}
footerOptions={options.footer}
enablePagination={options.footer?.enablePagination}
subData={subData}
cellHeight={options.cellHeight}
timeRange={timeRange}
/>
@ -62,7 +60,7 @@ export function TablePanel(props: Props) {
return tableElement;
}
const names = mainFrames.map((frame, index) => {
const names = frames.map((frame, index) => {
return {
label: getFrameDisplayName(frame),
value: index,

@ -1,6 +1,6 @@
import { PanelModel } from '@grafana/data';
import { createDataFrame, FieldType, PanelModel } from '@grafana/data';
import { tablePanelChangedHandler } from './migrations';
import { migrateFromParentRowIndexToNestedFrames, tablePanelChangedHandler } from './migrations';
describe('Table Migrations', () => {
it('migrates transform out to core transforms', () => {
@ -268,4 +268,64 @@ describe('Table Migrations', () => {
}
`);
});
it('migrates DataFrame[] from format using meta.custom.parentRowIndex to format using FieldType.nestedFrames', () => {
const mainFrame = (refId: string) => {
return createDataFrame({
refId,
fields: [
{
name: 'field',
type: FieldType.string,
config: {},
values: ['a', 'b', 'c'],
},
],
meta: {
preferredVisualisationType: 'table',
},
});
};
const subFrame = (index: number) => {
return createDataFrame({
refId: 'B',
fields: [
{
name: `field_${index}`,
type: FieldType.string,
config: {},
values: [`${index}_subA`, 'subB', 'subC'],
},
],
meta: {
preferredVisualisationType: 'table',
custom: {
parentRowIndex: index,
},
},
});
};
const oldFormat = [mainFrame('A'), mainFrame('B'), subFrame(0), subFrame(1)];
const newFormat = migrateFromParentRowIndexToNestedFrames(oldFormat);
expect(newFormat.length).toBe(2);
expect(newFormat[0].refId).toBe('A');
expect(newFormat[1].refId).toBe('B');
expect(newFormat[0].fields.length).toBe(1);
expect(newFormat[1].fields.length).toBe(2);
expect(newFormat[0].fields[0].name).toBe('field');
expect(newFormat[1].fields[0].name).toBe('field');
expect(newFormat[1].fields[1].name).toBe('nested');
expect(newFormat[1].fields[1].type).toBe(FieldType.nestedFrames);
expect(newFormat[1].fields[1].values.length).toBe(2);
expect(newFormat[1].fields[1].values[0][0].refId).toBe('B');
expect(newFormat[1].fields[1].values[1][0].refId).toBe('B');
expect(newFormat[1].fields[1].values[0][0].length).toBe(3);
expect(newFormat[1].fields[1].values[0][0].length).toBe(3);
expect(newFormat[1].fields[1].values[0][0].fields[0].name).toBe('field_0');
expect(newFormat[1].fields[1].values[1][0].fields[0].name).toBe('field_1');
expect(newFormat[1].fields[1].values[0][0].fields[0].values[0]).toBe('0_subA');
expect(newFormat[1].fields[1].values[1][0].fields[0].values[0]).toBe('1_subA');
});
});

@ -1,4 +1,4 @@
import { omitBy, isNil, isNumber, defaultTo } from 'lodash';
import { omitBy, isNil, isNumber, defaultTo, groupBy } from 'lodash';
import {
PanelModel,
@ -7,6 +7,8 @@ import {
ThresholdsMode,
ThresholdsConfig,
FieldConfig,
DataFrame,
FieldType,
} from '@grafana/data';
import { ReduceTransformerOptions } from '@grafana/data/src/transformations/transformers/reduce';
@ -249,3 +251,42 @@ export const tablePanelChangedHandler = (
return {};
};
const getMainFrames = (frames: DataFrame[] | null) => {
return frames?.filter((df) => df.meta?.custom?.parentRowIndex === undefined) || [frames?.[0]];
};
/**
* In 9.3 meta.custom.parentRowIndex was introduced to support sub-tables.
* In 10.2 meta.custom.parentRowIndex was deprecated in favor of FieldType.nestedFrames, which supports multiple nested frames.
* Migrate DataFrame[] from using meta.custom.parentRowIndex to using FieldType.nestedFrames
*/
export const migrateFromParentRowIndexToNestedFrames = (frames: DataFrame[] | null) => {
const migratedFrames: DataFrame[] = [];
const mainFrames = getMainFrames(frames).filter(
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
);
mainFrames?.forEach((frame) => {
const subFrames = frames?.filter((df) => frame.refId === df.refId && df.meta?.custom?.parentRowIndex !== undefined);
const subFramesGrouped = groupBy(subFrames, (frame: DataFrame) => frame.meta?.custom?.parentRowIndex);
const subFramesByIndex = Object.keys(subFramesGrouped).map((key) => subFramesGrouped[key]);
const migratedFrame = { ...frame };
if (subFrames && subFrames.length > 0) {
migratedFrame.fields.push({
name: 'nested',
type: FieldType.nestedFrames,
config: {},
values: subFramesByIndex,
});
}
migratedFrames.push(migratedFrame);
});
return migratedFrames;
};
export const hasDeprecatedParentRowIndex = (frames: DataFrame[] | null) => {
return frames?.some((df) => df.meta?.custom?.parentRowIndex !== undefined);
};

Loading…
Cancel
Save