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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 413 additions and 115 deletions

View File

@ -1538,7 +1538,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"packages/grafana-ui/src/components/Table/utils.test.ts:5381": [
"packages/grafana-ui/src/components/Table/utils.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
@ -1548,19 +1548,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
[0, 0, 0, "Unexpected any. Specify a different type.", "11"],
[0, 0, 0, "Unexpected any. Specify a different type.", "12"],
[0, 0, 0, "Unexpected any. Specify a different type.", "13"],
[0, 0, 0, "Unexpected any. Specify a different type.", "14"],
[0, 0, 0, "Unexpected any. Specify a different type.", "15"],
[0, 0, 0, "Unexpected any. Specify a different type.", "16"],
[0, 0, 0, "Unexpected any. Specify a different type.", "17"],
[0, 0, 0, "Unexpected any. Specify a different type.", "18"],
[0, 0, 0, "Unexpected any. Specify a different type.", "19"]
[0, 0, 0, "Unexpected any. Specify a different type.", "9"]
],
"packages/grafana-ui/src/components/Table/utils.ts:5381": [
"packages/grafana-ui/src/components/Table/utils.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],

View File

@ -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>
);
};

View File

@ -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} />

View File

@ -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;

View File

@ -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();
});
});
});

View File

@ -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>
) : (

View File

@ -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;
`,
};
};

View File

@ -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);

View File

@ -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;

View File

@ -52,7 +52,7 @@ const defaultProps = {
loading: false,
width: 800,
onCellFilterAdded: jest.fn(),
tableResult: dataFrame,
tableResult: [dataFrame],
splitOpenFn: (() => {}) as any,
range: {} as any,
timeZone: InternalTimeZones.utc,
@ -73,11 +73,13 @@ describe('TableContainer', () => {
});
it('should render 0 series returned on no items', () => {
const emptyFrames = {
name: 'TableResultName',
fields: [],
length: 0,
} as DataFrame;
const emptyFrames = [
{
name: 'TableResultName',
fields: [],
length: 0,
},
] as DataFrame[];
render(<TableContainer {...defaultProps} tableResult={emptyFrames} />);
expect(screen.getByText('0 series returned')).toBeInTheDocument();
});

View File

@ -1,7 +1,7 @@
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { ValueLinkConfig, applyFieldOverrides, TimeZone, SplitOpen } from '@grafana/data';
import { ValueLinkConfig, applyFieldOverrides, TimeZone, SplitOpen, DataFrame } from '@grafana/data';
import { Collapse, Table } from '@grafana/ui';
import { FilterItem } from '@grafana/ui/src/components/Table/types';
import { config } from 'app/core/config';
@ -35,15 +35,20 @@ const connector = connect(mapStateToProps, {});
type Props = TableContainerProps & ConnectedProps<typeof connector>;
export class TableContainer extends PureComponent<Props> {
getMainFrame(frames: DataFrame[] | null) {
return frames?.find((df) => df.meta?.custom?.parentRowIndex === undefined) || frames?.[0];
}
getTableHeight() {
const { tableResult } = this.props;
const mainFrame = this.getMainFrame(tableResult);
if (!tableResult || tableResult.length === 0) {
if (!mainFrame || mainFrame.length === 0) {
return 200;
}
// tries to estimate table height
return Math.max(Math.min(600, tableResult.length * 35) + 35);
return Math.max(Math.min(600, mainFrame.length * 35) + 35);
}
render() {
@ -51,11 +56,11 @@ export class TableContainer extends PureComponent<Props> {
const height = this.getTableHeight();
const tableWidth = width - config.theme.panelPadding * 2 - PANEL_BORDER;
let dataFrame = tableResult;
let dataFrames = tableResult;
if (dataFrame?.length) {
dataFrame = applyFieldOverrides({
data: [dataFrame],
if (dataFrames?.length) {
dataFrames = applyFieldOverrides({
data: dataFrames,
timeZone,
theme: config.theme2,
replaceVariables: (v: string) => v,
@ -63,29 +68,35 @@ export class TableContainer extends PureComponent<Props> {
defaults: {},
overrides: [],
},
})[0];
});
// Bit of code smell here. We need to add links here to the frame modifying the frame on every render.
// Should work fine in essence but still not the ideal way to pass props. In logs container we do this
// differently and sidestep this getLinks API on a dataframe
for (const field of dataFrame.fields) {
field.getLinks = (config: ValueLinkConfig) => {
return getFieldLinksForExplore({
field,
rowIndex: config.valueRowIndex!,
splitOpenFn,
range,
dataFrame: dataFrame!,
});
};
for (const frame of dataFrames) {
for (const field of frame.fields) {
field.getLinks = (config: ValueLinkConfig) => {
return getFieldLinksForExplore({
field,
rowIndex: config.valueRowIndex!,
splitOpenFn,
range,
dataFrame: frame!,
});
};
}
}
}
const mainFrame = this.getMainFrame(dataFrames);
const subFrames = dataFrames?.filter((df) => df.meta?.custom?.parentRowIndex !== undefined);
return (
<Collapse label="Table" loading={loading} isOpen>
{dataFrame?.length ? (
{mainFrame?.length ? (
<Table
ariaLabel={ariaLabel}
data={dataFrame}
data={mainFrame}
subData={subFrames}
width={tableWidth}
height={height}
onCellFilterAdded={onCellFilterAdded}

View File

@ -965,7 +965,7 @@ export const processQueryResponse = (
loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming,
showLogs: !!logsResult,
showMetrics: !!graphResult,
showTable: !!tableResult,
showTable: !!tableResult?.length,
showTrace: !!traceFrames.length,
showNodeGraph: !!nodeGraphFrames.length,
showFlameGraph: !!flameGraphFrames.length,

View File

@ -208,17 +208,18 @@ describe('decorateWithTableResult', () => {
const panelResult = await lastValueFrom(decorateWithTableResult(panelData));
let theResult = panelResult.tableResult;
let theResultTable = theResult?.[0];
expect(theResult?.fields[0].name).toEqual('value');
expect(theResult?.fields[1].name).toEqual('time');
expect(theResult?.fields[2].name).toEqual('tsNs');
expect(theResult?.fields[3].name).toEqual('message');
expect(theResult?.fields[1].display).not.toBeNull();
expect(theResult?.length).toBe(3);
expect(theResultTable?.fields[0].name).toEqual('value');
expect(theResultTable?.fields[1].name).toEqual('time');
expect(theResultTable?.fields[2].name).toEqual('tsNs');
expect(theResultTable?.fields[3].name).toEqual('message');
expect(theResultTable?.fields[1].display).not.toBeNull();
expect(theResultTable?.length).toBe(3);
// I don't understand the purpose of the code below, feels like this belongs in toDataFrame tests?
// Same data though a DataFrame
theResult = toDataFrame(
theResultTable = toDataFrame(
new TableModel({
columns: [
{ text: 'value', type: 'number' },
@ -234,12 +235,12 @@ describe('decorateWithTableResult', () => {
type: 'table',
})
);
expect(theResult.fields[0].name).toEqual('value');
expect(theResult.fields[1].name).toEqual('time');
expect(theResult.fields[2].name).toEqual('tsNs');
expect(theResult.fields[3].name).toEqual('message');
expect(theResult.fields[1].display).not.toBeNull();
expect(theResult.length).toBe(3);
expect(theResultTable.fields[0].name).toEqual('value');
expect(theResultTable.fields[1].name).toEqual('time');
expect(theResultTable.fields[2].name).toEqual('tsNs');
expect(theResultTable.fields[3].name).toEqual('message');
expect(theResultTable.fields[1].display).not.toBeNull();
expect(theResultTable.length).toBe(3);
});
it('should do join transform if all series are timeseries', async () => {
@ -264,13 +265,14 @@ describe('decorateWithTableResult', () => {
const panelData = createExplorePanelData({ tableFrames });
const panelResult = await lastValueFrom(decorateWithTableResult(panelData));
const result = panelResult.tableResult;
const tableResult = result?.[0];
expect(result?.fields[0].name).toBe('Time');
expect(result?.fields[1].name).toBe('A-series');
expect(result?.fields[2].name).toBe('B-series');
expect(result?.fields[0].values.toArray()).toEqual([100, 200, 300]);
expect(result?.fields[1].values.toArray()).toEqual([4, 5, 6]);
expect(result?.fields[2].values.toArray()).toEqual([4, 5, 6]);
expect(tableResult?.fields[0].name).toBe('Time');
expect(tableResult?.fields[1].name).toBe('A-series');
expect(tableResult?.fields[2].name).toBe('B-series');
expect(tableResult?.fields[0].values.toArray()).toEqual([100, 200, 300]);
expect(tableResult?.fields[1].values.toArray()).toEqual([4, 5, 6]);
expect(tableResult?.fields[2].values.toArray()).toEqual([4, 5, 6]);
});
it('should not override fields display property when filled', async () => {
@ -286,7 +288,7 @@ describe('decorateWithTableResult', () => {
const panelData = createExplorePanelData({ tableFrames });
const panelResult = await lastValueFrom(decorateWithTableResult(panelData));
expect(panelResult.tableResult?.fields[0].display).toBe(displayFunctionMock);
expect(panelResult.tableResult?.[0]?.fields[0].display).toBe(displayFunctionMock);
});
it('should return null when passed empty array', async () => {

View File

@ -137,20 +137,20 @@ export const decorateWithTableResult = (data: ExplorePanelData): Observable<Expl
return transformer.pipe(
map((frames) => {
const frame = frames[0];
// set display processor
for (const field of frame.fields) {
field.display =
field.display ??
getDisplayProcessor({
field,
theme: config.theme2,
timeZone: data.request?.timezone ?? 'browser',
});
for (const frame of frames) {
// set display processor
for (const field of frame.fields) {
field.display =
field.display ??
getDisplayProcessor({
field,
theme: config.theme2,
timeZone: data.request?.timezone ?? 'browser',
});
}
}
return { ...data, tableResult: frame };
return { ...data, tableResult: frames };
})
);
};

View File

@ -94,7 +94,7 @@ export class TablePanel extends Component<Props> {
dispatch(applyFilterFromTable({ datasource: datasourceRef, key, operator, value }));
};
renderTable(frame: DataFrame, width: number, height: number) {
renderTable(frame: DataFrame, width: number, height: number, subData?: DataFrame[]) {
const { options } = this.props;
return (
@ -111,6 +111,7 @@ export class TablePanel extends Component<Props> {
onCellFilterAdded={this.onCellFilterAdded}
footerOptions={options.footer}
enablePagination={options.footer?.enablePagination}
subData={subData}
/>
);
}
@ -123,8 +124,10 @@ export class TablePanel extends Component<Props> {
const { data, height, width, options, fieldConfig, id } = this.props;
const frames = data.series;
const count = frames?.length;
const hasFields = frames[0]?.fields.length;
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;
if (!count || !hasFields) {
return <PanelDataErrorView panelId={id} fieldConfig={fieldConfig} data={data} />;
@ -133,17 +136,19 @@ export class TablePanel extends Component<Props> {
if (count > 1) {
const inputHeight = config.theme2.spacing.gridSize * config.theme2.components.height.md;
const padding = 8 * 2;
const currentIndex = this.getCurrentFrameIndex(frames, options);
const names = frames.map((frame, index) => {
const currentIndex = this.getCurrentFrameIndex(mainFrames, options);
const names = mainFrames.map((frame, index) => {
return {
label: getFrameDisplayName(frame),
value: index,
};
});
const main = mainFrames[currentIndex];
const subData = subFrames.filter((f) => f.refId === main.refId);
return (
<div className={tableStyles.wrapper}>
{this.renderTable(data.series[currentIndex], width, height - inputHeight - padding)}
{this.renderTable(main, width, height - inputHeight - padding, subData)}
<div className={tableStyles.selectWrapper}>
<Select options={names} value={names[currentIndex]} onChange={this.onChangeTableSelection} />
</div>
@ -151,7 +156,8 @@ export class TablePanel extends Component<Props> {
);
}
return this.renderTable(data.series[0], width, height);
const subData = frames.filter((f) => f.meta?.custom?.parentRowIndex !== undefined);
return this.renderTable(data.series[0], width, height, subData);
}
}

View File

@ -148,7 +148,7 @@ export interface ExploreItemState {
/**
* Table model that combines all query table results into a single table.
*/
tableResult: DataFrame | null;
tableResult: DataFrame[] | null;
/**
* React keys for rendering of QueryRows
@ -249,6 +249,6 @@ export interface ExplorePanelData extends PanelData {
nodeGraphFrames: DataFrame[];
flameGraphFrames: DataFrame[];
graphResult: DataFrame[] | null;
tableResult: DataFrame | null;
tableResult: DataFrame[] | null;
logsResult: LogsModel | null;
}