From d895cad92a39a23caa6277223bb3e42f4f48e996 Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Wed, 23 Nov 2022 19:30:45 +0100 Subject: [PATCH] [v9.3.x] Explore: Sub-tables support for Table component (#59250) 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 (cherry picked from commit 183b279274d6c1eea66ea5a545807d77bb4b6b9e) Co-authored-by: Andre Pereira --- .betterer.results | 16 +--- .../src/components/Table/RowExpander.tsx | 41 ++++++++ .../grafana-ui/src/components/Table/Table.mdx | 8 ++ .../src/components/Table/Table.story.tsx | 76 ++++++++++++++- .../src/components/Table/Table.test.tsx | 50 ++++++++++ .../grafana-ui/src/components/Table/Table.tsx | 96 ++++++++++++++++--- .../grafana-ui/src/components/Table/styles.ts | 12 ++- .../Table/{utils.test.ts => utils.test.tsx} | 40 +++++--- .../components/Table/{utils.ts => utils.tsx} | 32 ++++++- .../features/explore/TableContainer.test.tsx | 14 +-- .../app/features/explore/TableContainer.tsx | 51 ++++++---- public/app/features/explore/state/query.ts | 2 +- .../features/explore/utils/decorators.test.ts | 42 ++++---- .../app/features/explore/utils/decorators.ts | 24 ++--- public/app/plugins/panel/table/TablePanel.tsx | 20 ++-- public/app/types/explore.ts | 4 +- 16 files changed, 413 insertions(+), 115 deletions(-) create mode 100644 packages/grafana-ui/src/components/Table/RowExpander.tsx rename packages/grafana-ui/src/components/Table/{utils.test.ts => utils.test.tsx} (92%) rename packages/grafana-ui/src/components/Table/{utils.ts => utils.tsx} (89%) diff --git a/.betterer.results b/.betterer.results index 7b1c8297109..49f5766f7d4 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/packages/grafana-ui/src/components/Table/RowExpander.tsx b/packages/grafana-ui/src/components/Table/RowExpander.tsx new file mode 100644 index 00000000000..1e4f5a52cc0 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/RowExpander.tsx @@ -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; + setExpandedIndexes: (indexes: Set) => void; +} + +export const RowExpander: FC = ({ 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 ( +
{ + const newExpandedIndexes = new Set(expandedIndexes); + if (isExpanded) { + newExpandedIndexes.delete(row.index); + } else { + newExpandedIndexes.add(row.index); + } + setExpandedIndexes(newExpandedIndexes); + }} + > + +
+ ); +}; diff --git a/packages/grafana-ui/src/components/Table/Table.mdx b/packages/grafana-ui/src/components/Table/Table.mdx index 685ea7f424b..44ca807b86f 100644 --- a/packages/grafana-ui/src/components/Table/Table.mdx +++ b/packages/grafana-ui/src/components/Table/Table.mdx @@ -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 diff --git a/packages/grafana-ui/src/components/Table/Table.story.tsx b/packages/grafana-ui/src/components/Table/Table.story.tsx index 75a7cd65fd0..a300ecb99a2 100644 --- a/packages/grafana-ui/src/components/Table/Table.story.tsx +++ b/packages/grafana-ui/src/components/Table/Table.story.tsx @@ -36,7 +36,7 @@ const meta: ComponentMeta = { args: { width: 700, height: 500, - columnMinWidth: 150, + columnMinWidth: 130, }, }; @@ -98,6 +98,61 @@ function buildData(theme: GrafanaTheme2, config: Record): D return prepDataForStorybook([data], theme)[0]; } +function buildSubTablesData(theme: GrafanaTheme2, config: Record): 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 = (args) => { + const theme = useTheme2(); + const data = buildData(theme, {}); + const subData = buildSubTablesData(theme, { + Progress: { + custom: { + displayMode: 'gradient-gauge', + }, + thresholds: defaultThresholds, + }, + }); + + return ( +
+ + + ); +}; + export default meta; diff --git a/packages/grafana-ui/src/components/Table/Table.test.tsx b/packages/grafana-ui/src/components/Table/Table.test.tsx index bd11b19bde1..b6ce1f0d003 100644 --- a/packages/grafana-ui/src/components/Table/Table.test.tsx +++ b/packages/grafana-ui/src/components/Table/Table.test.tsx @@ -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(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index fe69cf86ba3..dc3103067ec 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -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(null); + const listRef = useRef(null); const tableDivRef = useRef(null); - const fixedSizeListScrollbarRef = useRef(null); + const variableSizeListScrollbarRef = useRef(null); const tableStyles = useStyles2(getTableStyles); const theme = useTheme2(); const headerHeight = noHeader ? 0 : tableStyles.cellHeight; const [footerItems, setFooterItems] = useState(footerValues); + const [expandedIndexes, setExpandedIndexes] = useState>(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 ( +
+
+ + ); + } + } + 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 (
+ {/*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) => ( {
); }, - [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) => {
{!noHeader && } {itemCount > 0 ? ( -
+
- {RenderRow} - +
) : ( diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index 5d08f95d6a6..0f769c4c933 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -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; + `, }; }; diff --git a/packages/grafana-ui/src/components/Table/utils.test.ts b/packages/grafana-ui/src/components/Table/utils.test.tsx similarity index 92% rename from packages/grafana-ui/src/components/Table/utils.test.ts rename to packages/grafana-ui/src/components/Table/utils.test.tsx index 1dad733f3ca..293c3511cfa 100644 --- a/packages/grafana-ui/src/components/Table/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/utils.test.tsx @@ -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); diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.tsx similarity index 89% rename from packages/grafana-ui/src/components/Table/utils.ts rename to packages/grafana-ui/src/components/Table/utils.tsx index ce988462f05..fdce74d6853 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.tsx @@ -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, + setExpandedIndexes: (indexes: Set) => 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 ; + }, + 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; diff --git a/public/app/features/explore/TableContainer.test.tsx b/public/app/features/explore/TableContainer.test.tsx index ad4bce55640..c35304a5587 100644 --- a/public/app/features/explore/TableContainer.test.tsx +++ b/public/app/features/explore/TableContainer.test.tsx @@ -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(); expect(screen.getByText('0 series returned')).toBeInTheDocument(); }); diff --git a/public/app/features/explore/TableContainer.tsx b/public/app/features/explore/TableContainer.tsx index 40c986fea62..a999d82afd7 100644 --- a/public/app/features/explore/TableContainer.tsx +++ b/public/app/features/explore/TableContainer.tsx @@ -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; export class TableContainer extends PureComponent { + 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 { 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 { 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 ( - {dataFrame?.length ? ( + {mainFrame?.length ? (
{ 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 () => { diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index 970e001b4c0..9ab44c8270b 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -137,20 +137,20 @@ export const decorateWithTableResult = (data: ExplorePanelData): Observable { - 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 }; }) ); }; diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx index c7f105259c8..6ec1ddbf42f 100644 --- a/public/app/plugins/panel/table/TablePanel.tsx +++ b/public/app/plugins/panel/table/TablePanel.tsx @@ -94,7 +94,7 @@ export class TablePanel extends Component { 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 { onCellFilterAdded={this.onCellFilterAdded} footerOptions={options.footer} enablePagination={options.footer?.enablePagination} + subData={subData} /> ); } @@ -123,8 +124,10 @@ export class TablePanel extends Component { 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 ; @@ -133,17 +136,19 @@ export class TablePanel extends Component { 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 (
- {this.renderTable(data.series[currentIndex], width, height - inputHeight - padding)} + {this.renderTable(main, width, height - inputHeight - padding, subData)}