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