mirror of https://github.com/grafana/grafana
prometheushacktoberfestmetricsmonitoringalertinggrafanagoinfluxdbmysqlpostgresanalyticsdata-visualizationdashboardbusiness-intelligenceelasticsearch
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1055 lines
34 KiB
1055 lines
34 KiB
import 'react-data-grid/lib/styles.css';
|
|
import { css } from '@emotion/css';
|
|
import { useMemo, useState, useLayoutEffect, useCallback, useRef, useEffect } from 'react';
|
|
import DataGrid, { RenderCellProps, RenderRowProps, Row, SortColumn, DataGridHandle } from 'react-data-grid';
|
|
import { useMeasure } from 'react-use';
|
|
|
|
import {
|
|
DataFrame,
|
|
DataHoverClearEvent,
|
|
DataHoverEvent,
|
|
Field,
|
|
fieldReducers,
|
|
FieldType,
|
|
formattedValueToString,
|
|
getDefaultTimeRange,
|
|
GrafanaTheme2,
|
|
ReducerID,
|
|
} from '@grafana/data';
|
|
import { TableCellDisplayMode } from '@grafana/schema';
|
|
|
|
import { useStyles2, useTheme2 } from '../../../themes';
|
|
import { t, Trans } from '../../../utils/i18n';
|
|
import { ContextMenu } from '../../ContextMenu/ContextMenu';
|
|
import { MenuItem } from '../../Menu/MenuItem';
|
|
import { Pagination } from '../../Pagination/Pagination';
|
|
import { PanelContext, usePanelContext } from '../../PanelChrome';
|
|
import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspector';
|
|
|
|
import { HeaderCell } from './Cells/HeaderCell';
|
|
import { RowExpander } from './Cells/RowExpander';
|
|
import { TableCellNG } from './Cells/TableCellNG';
|
|
import { COLUMN, TABLE } from './constants';
|
|
import {
|
|
TableNGProps,
|
|
FilterType,
|
|
TableRow,
|
|
TableSummaryRow,
|
|
ColumnTypes,
|
|
TableColumnResizeActionCallback,
|
|
TableColumn,
|
|
TableFieldOptionsType,
|
|
ScrollPosition,
|
|
CellColors,
|
|
} from './types';
|
|
import {
|
|
frameToRecords,
|
|
getCellColors,
|
|
getCellHeightCalculator,
|
|
getComparator,
|
|
getDefaultRowHeight,
|
|
getFooterItemNG,
|
|
getFooterStyles,
|
|
getIsNestedTable,
|
|
getRowHeight,
|
|
getTextAlign,
|
|
handleSort,
|
|
MapFrameToGridOptions,
|
|
shouldTextOverflow,
|
|
} from './utils';
|
|
|
|
export function TableNG(props: TableNGProps) {
|
|
const {
|
|
cellHeight,
|
|
enablePagination,
|
|
enableVirtualization = true,
|
|
fieldConfig,
|
|
footerOptions,
|
|
height,
|
|
initialSortBy,
|
|
noHeader,
|
|
onCellFilterAdded,
|
|
onColumnResize,
|
|
onSortByChange,
|
|
width,
|
|
data,
|
|
enableSharedCrosshair,
|
|
showTypeIcons,
|
|
replaceVariables,
|
|
} = props;
|
|
|
|
const initialSortColumns = useMemo<SortColumn[]>(() => {
|
|
const initialSort = initialSortBy?.map(({ displayName, desc }) => {
|
|
const matchingField = data.fields.find(({ state }) => state?.displayName === displayName);
|
|
const columnKey = matchingField?.name || displayName;
|
|
|
|
return {
|
|
columnKey,
|
|
direction: desc ? ('DESC' as const) : ('ASC' as const),
|
|
};
|
|
});
|
|
return initialSort ?? [];
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
/* ------------------------------- Local state ------------------------------ */
|
|
const [revId, setRevId] = useState(0);
|
|
const [contextMenuProps, setContextMenuProps] = useState<{
|
|
rowIdx?: number;
|
|
value: string;
|
|
mode?: TableCellInspectorMode.code | TableCellInspectorMode.text;
|
|
top?: number;
|
|
left?: number;
|
|
} | null>(null);
|
|
const [isInspecting, setIsInspecting] = useState(false);
|
|
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
|
const [filter, setFilter] = useState<FilterType>({});
|
|
const [page, setPage] = useState(0);
|
|
// This state will trigger re-render for recalculating row heights
|
|
const [, setResizeTrigger] = useState(0);
|
|
const [, setReadyForRowHeightCalc] = useState(false);
|
|
const [sortColumns, setSortColumns] = useState<readonly SortColumn[]>(initialSortColumns);
|
|
const [expandedRows, setExpandedRows] = useState<number[]>([]);
|
|
const [isNestedTable, setIsNestedTable] = useState(false);
|
|
const scrollPositionRef = useRef<ScrollPosition>({ x: 0, y: 0 });
|
|
const [hasScroll, setHasScroll] = useState(false);
|
|
|
|
/* ------------------------------- Local refs ------------------------------- */
|
|
const crossFilterOrder = useRef<string[]>([]);
|
|
const crossFilterRows = useRef<Record<string, TableRow[]>>({});
|
|
const headerCellRefs = useRef<Record<string, HTMLDivElement>>({});
|
|
// TODO: This ref persists sortColumns between renders. setSortColumns is still used to trigger re-render
|
|
const sortColumnsRef = useRef<SortColumn[]>(initialSortColumns);
|
|
const prevProps = useRef(props);
|
|
const calcsRef = useRef<string[]>([]);
|
|
const [paginationWrapperRef, { height: paginationHeight }] = useMeasure<HTMLDivElement>();
|
|
|
|
const theme = useTheme2();
|
|
const panelContext = usePanelContext();
|
|
|
|
const isFooterVisible = Boolean(footerOptions?.show && footerOptions.reducer?.length);
|
|
const isCountRowsSet = Boolean(
|
|
footerOptions?.countRows &&
|
|
footerOptions.reducer &&
|
|
footerOptions.reducer.length &&
|
|
footerOptions.reducer[0] === ReducerID.count
|
|
);
|
|
const tableRef = useRef<DataGridHandle | null>(null);
|
|
|
|
/* --------------------------------- Effects -------------------------------- */
|
|
useEffect(() => {
|
|
// TODO: there is a use case when adding a new column to the table doesn't update the table
|
|
if (
|
|
prevProps.current.data.fields.length !== props.data.fields.length ||
|
|
prevProps.current.fieldConfig?.overrides !== fieldConfig?.overrides ||
|
|
prevProps.current.fieldConfig?.defaults !== fieldConfig?.defaults
|
|
) {
|
|
setRevId(revId + 1);
|
|
}
|
|
prevProps.current = props;
|
|
}, [props, revId, fieldConfig?.overrides, fieldConfig?.defaults]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useLayoutEffect(() => {
|
|
if (!isContextMenuOpen) {
|
|
return;
|
|
}
|
|
|
|
function onClick(event: MouseEvent) {
|
|
setIsContextMenuOpen(false);
|
|
}
|
|
|
|
addEventListener('click', onClick);
|
|
|
|
return () => {
|
|
removeEventListener('click', onClick);
|
|
};
|
|
}, [isContextMenuOpen]);
|
|
|
|
useEffect(() => {
|
|
const hasNestedFrames = getIsNestedTable(props.data);
|
|
setIsNestedTable(hasNestedFrames);
|
|
}, [props.data]);
|
|
|
|
useEffect(() => {
|
|
const el = tableRef.current;
|
|
if (el) {
|
|
const gridElement = el?.element;
|
|
if (gridElement) {
|
|
setHasScroll(
|
|
gridElement.scrollHeight > gridElement.clientHeight || gridElement.scrollWidth > gridElement.clientWidth
|
|
);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// TODO: this is a hack to force the column width to update when the fieldConfig changes
|
|
const columnWidth = useMemo(() => {
|
|
setRevId(revId + 1);
|
|
return fieldConfig?.defaults?.custom?.width || 'auto';
|
|
}, [fieldConfig]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const defaultRowHeight = getDefaultRowHeight(theme, cellHeight);
|
|
const defaultLineHeight = theme.typography.body.lineHeight * theme.typography.fontSize;
|
|
const panelPaddingHeight = theme.components.panel.padding * theme.spacing.gridSize * 2;
|
|
|
|
/* ------------------------------ Rows & Columns ----------------------------- */
|
|
const rows = useMemo(() => frameToRecords(props.data), [frameToRecords, props.data]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Create a map of column key to column type
|
|
const columnTypes = useMemo(
|
|
() => props.data.fields.reduce<ColumnTypes>((acc, { name, type }) => ({ ...acc, [name]: type }), {}),
|
|
[props.data.fields]
|
|
);
|
|
|
|
// Create a map of column key to text wrap
|
|
const textWraps = useMemo(
|
|
() =>
|
|
props.data.fields.reduce<{ [key: string]: boolean }>(
|
|
(acc, { name, config }) => ({ ...acc, [name]: config?.custom?.cellOptions?.wrapText ?? false }),
|
|
{}
|
|
),
|
|
[props.data.fields]
|
|
);
|
|
|
|
const textWrap = useMemo(() => Object.values(textWraps).some(Boolean), [textWraps]);
|
|
const styles = useStyles2(getStyles);
|
|
|
|
// Create a function to get column widths for text wrapping calculations
|
|
const getColumnWidths = useCallback(() => {
|
|
const widths: Record<string, number> = {};
|
|
|
|
// Set default widths from field config if they exist
|
|
props.data.fields.forEach(({ name, config }) => {
|
|
const configWidth = config?.custom?.width;
|
|
const totalWidth = typeof configWidth === 'number' ? configWidth : COLUMN.DEFAULT_WIDTH;
|
|
// subtract out padding and 1px right border
|
|
const contentWidth = totalWidth - 2 * TABLE.CELL_PADDING - 1;
|
|
widths[name] = contentWidth;
|
|
});
|
|
|
|
// Measure actual widths if available
|
|
Object.keys(headerCellRefs.current).forEach((key) => {
|
|
const headerCell = headerCellRefs.current[key];
|
|
|
|
if (headerCell.offsetWidth > 0) {
|
|
widths[key] = headerCell.offsetWidth;
|
|
}
|
|
});
|
|
|
|
return widths;
|
|
}, [props.data.fields]);
|
|
|
|
const headersLength = useMemo(() => {
|
|
return props.data.fields.length;
|
|
}, [props.data.fields]);
|
|
|
|
const fieldDisplayType = useMemo(() => {
|
|
return props.data.fields.reduce<Record<string, TableCellDisplayMode>>((acc, { config, name }) => {
|
|
if (config?.custom?.cellOptions?.type) {
|
|
acc[name] = config.custom.cellOptions.type;
|
|
}
|
|
return acc;
|
|
}, {});
|
|
}, [props.data.fields]);
|
|
|
|
// Clean up fieldsData to simplify
|
|
const fieldsData = useMemo(
|
|
() => ({
|
|
headersLength,
|
|
textWraps,
|
|
columnTypes,
|
|
fieldDisplayType,
|
|
columnWidths: getColumnWidths(),
|
|
}),
|
|
[textWraps, columnTypes, getColumnWidths, headersLength, fieldDisplayType]
|
|
);
|
|
|
|
const getDisplayedValue = (row: TableRow, key: string) => {
|
|
const field = props.data.fields.find((field) => field.name === key)!;
|
|
const displayedValue = formattedValueToString(field.display!(row[key]));
|
|
return displayedValue;
|
|
};
|
|
|
|
// Filter rows
|
|
const filteredRows = useMemo(() => {
|
|
const filterValues = Object.entries(filter);
|
|
if (filterValues.length === 0) {
|
|
// reset cross filter order
|
|
crossFilterOrder.current = [];
|
|
return rows;
|
|
}
|
|
|
|
// Update crossFilterOrder
|
|
const filterKeys = new Set(filterValues.map(([key]) => key));
|
|
filterKeys.forEach((key) => {
|
|
if (!crossFilterOrder.current.includes(key)) {
|
|
// Each time a filter is added or removed, it is always a single filter.
|
|
// When adding a new filter, it is always appended to the end, maintaining the order.
|
|
crossFilterOrder.current.push(key);
|
|
}
|
|
});
|
|
// Remove keys from crossFilterOrder that are no longer present in the current filter values
|
|
crossFilterOrder.current = crossFilterOrder.current.filter((key) => filterKeys.has(key));
|
|
|
|
// reset crossFilterRows
|
|
crossFilterRows.current = {};
|
|
|
|
return rows.filter((row) => {
|
|
for (const [key, value] of filterValues) {
|
|
const displayedValue = getDisplayedValue(row, key);
|
|
if (!value.filteredSet.has(displayedValue)) {
|
|
return false;
|
|
}
|
|
// collect rows for crossFilter
|
|
if (!crossFilterRows.current[key]) {
|
|
crossFilterRows.current[key] = [row];
|
|
} else {
|
|
crossFilterRows.current[key].push(row);
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}, [rows, filter, props.data.fields]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Sort rows
|
|
const sortedRows = useMemo(() => {
|
|
const comparators = sortColumns.map(({ columnKey }) => getComparator(columnTypes[columnKey]));
|
|
const sortDirs = sortColumns.map(({ direction }) => (direction === 'ASC' ? 1 : -1));
|
|
|
|
if (sortColumns.length === 0) {
|
|
return filteredRows;
|
|
}
|
|
|
|
return filteredRows.slice().sort((a, b) => {
|
|
let result = 0;
|
|
let sortIndex = 0;
|
|
|
|
for (const { columnKey } of sortColumns) {
|
|
const compare = comparators[sortIndex];
|
|
result = sortDirs[sortIndex] * compare(a[columnKey], b[columnKey]);
|
|
|
|
if (result !== 0) {
|
|
break;
|
|
}
|
|
|
|
sortIndex += 1;
|
|
}
|
|
|
|
return result;
|
|
});
|
|
}, [filteredRows, sortColumns, columnTypes]);
|
|
|
|
// Paginated rows
|
|
// TODO consolidate calculations into pagination wrapper component and only use when needed
|
|
const numRows = sortedRows.length;
|
|
// calculate number of rowsPerPage based on height stack
|
|
let headerCellHeight = TABLE.MAX_CELL_HEIGHT;
|
|
if (noHeader) {
|
|
headerCellHeight = 0;
|
|
} else if (!noHeader && Object.keys(headerCellRefs.current).length > 0) {
|
|
headerCellHeight = headerCellRefs.current[Object.keys(headerCellRefs.current)[0]].getBoundingClientRect().height;
|
|
}
|
|
let rowsPerPage = Math.floor(
|
|
(height - headerCellHeight - TABLE.SCROLL_BAR_WIDTH - paginationHeight - panelPaddingHeight) / defaultRowHeight
|
|
);
|
|
// if footer calcs are on, remove one row per page
|
|
if (isFooterVisible) {
|
|
rowsPerPage -= 1;
|
|
}
|
|
if (rowsPerPage < 1) {
|
|
// avoid 0 or negative rowsPerPage
|
|
rowsPerPage = 1;
|
|
}
|
|
const numberOfPages = Math.ceil(numRows / rowsPerPage);
|
|
if (page > numberOfPages) {
|
|
// resets pagination to end
|
|
setPage(numberOfPages - 1);
|
|
}
|
|
// calculate row range for pagination summary display
|
|
const itemsRangeStart = page * rowsPerPage + 1;
|
|
let displayedEnd = itemsRangeStart + rowsPerPage - 1;
|
|
if (displayedEnd > numRows) {
|
|
displayedEnd = numRows;
|
|
}
|
|
const smallPagination = width < TABLE.PAGINATION_LIMIT;
|
|
|
|
const paginatedRows = useMemo(() => {
|
|
const pageOffset = page * rowsPerPage;
|
|
return sortedRows.slice(pageOffset, pageOffset + rowsPerPage);
|
|
}, [rows, sortedRows, page, rowsPerPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useMemo(() => {
|
|
calcsRef.current = props.data.fields.map((field, index) => {
|
|
if (field.state?.calcs) {
|
|
delete field.state?.calcs;
|
|
}
|
|
if (isCountRowsSet) {
|
|
return index === 0 ? `${sortedRows.length}` : '';
|
|
}
|
|
if (index === 0) {
|
|
const footerCalcReducer = footerOptions?.reducer?.[0];
|
|
return footerCalcReducer ? fieldReducers.get(footerCalcReducer).name : '';
|
|
}
|
|
return getFooterItemNG(sortedRows, field, footerOptions);
|
|
});
|
|
}, [sortedRows, props.data.fields, footerOptions, isCountRowsSet]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const onCellExpand = (rowIdx: number) => {
|
|
if (!expandedRows.includes(rowIdx)) {
|
|
setExpandedRows([...expandedRows, rowIdx]);
|
|
} else {
|
|
const currentExpandedRows = expandedRows;
|
|
const indexToRemove = currentExpandedRows.indexOf(rowIdx);
|
|
if (indexToRemove > -1) {
|
|
currentExpandedRows.splice(indexToRemove, 1);
|
|
setExpandedRows(currentExpandedRows);
|
|
}
|
|
}
|
|
setResizeTrigger((prev) => prev + 1);
|
|
};
|
|
|
|
const { ctx, avgCharWidth } = useMemo(() => {
|
|
const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`;
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d')!;
|
|
// set in grafana/data in createTypography.ts
|
|
const letterSpacing = 0.15;
|
|
|
|
ctx.letterSpacing = `${letterSpacing}px`;
|
|
ctx.font = font;
|
|
let txt =
|
|
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s";
|
|
const txtWidth = ctx.measureText(txt).width;
|
|
const avgCharWidth = txtWidth / txt.length + letterSpacing;
|
|
|
|
return {
|
|
ctx,
|
|
font,
|
|
avgCharWidth,
|
|
};
|
|
}, [theme.typography.fontSize, theme.typography.fontFamily]);
|
|
|
|
const columns = useMemo(
|
|
() =>
|
|
mapFrameToDataGrid({
|
|
frame: props.data,
|
|
calcsRef,
|
|
options: {
|
|
columnTypes,
|
|
textWraps,
|
|
columnWidth,
|
|
crossFilterOrder,
|
|
crossFilterRows,
|
|
defaultLineHeight,
|
|
defaultRowHeight,
|
|
expandedRows,
|
|
filter,
|
|
headerCellRefs,
|
|
isCountRowsSet,
|
|
onCellFilterAdded,
|
|
ctx,
|
|
onSortByChange,
|
|
rows,
|
|
// INFO: sortedRows is for correct row indexing for cell background coloring
|
|
sortedRows,
|
|
setContextMenuProps,
|
|
setFilter,
|
|
setIsInspecting,
|
|
setSortColumns,
|
|
sortColumnsRef,
|
|
styles,
|
|
theme,
|
|
showTypeIcons,
|
|
replaceVariables,
|
|
...props,
|
|
},
|
|
handlers: {
|
|
onCellExpand,
|
|
onColumnResize: onColumnResize!,
|
|
},
|
|
// Adjust table width to account for the scroll bar width
|
|
availableWidth: width - (hasScroll ? TABLE.SCROLL_BAR_WIDTH + TABLE.SCROLL_BAR_MARGIN : 0),
|
|
}),
|
|
[props.data, calcsRef, filter, expandedRows, expandedRows.length, footerOptions, width, hasScroll, sortedRows] // eslint-disable-line react-hooks/exhaustive-deps
|
|
);
|
|
|
|
// This effect needed to set header cells refs before row height calculation
|
|
useLayoutEffect(() => {
|
|
setReadyForRowHeightCalc(Object.keys(headerCellRefs.current).length > 0);
|
|
}, [columns]);
|
|
|
|
const renderMenuItems = () => {
|
|
return (
|
|
<>
|
|
<MenuItem
|
|
label={t('grafana-ui.table.inspect-menu-label', 'Inspect value')}
|
|
onClick={() => {
|
|
setIsInspecting(true);
|
|
}}
|
|
className={styles.menuItem}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const cellHeightCalc = useMemo(() => {
|
|
return getCellHeightCalculator(ctx, defaultLineHeight, defaultRowHeight, TABLE.CELL_PADDING);
|
|
}, [ctx, defaultLineHeight, defaultRowHeight]);
|
|
|
|
const calculateRowHeight = useCallback(
|
|
(row: TableRow) => {
|
|
// Logic for sub-tables
|
|
if (Number(row.__depth) === 1 && !expandedRows.includes(Number(row.__index))) {
|
|
return 0;
|
|
} else if (Number(row.__depth) === 1 && expandedRows.includes(Number(row.__index))) {
|
|
const headerCount = row?.data?.meta?.custom?.noHeader ? 0 : 1;
|
|
return defaultRowHeight * (row.data?.length ?? 0 + headerCount); // TODO this probably isn't very robust
|
|
}
|
|
return getRowHeight(row, cellHeightCalc, avgCharWidth, defaultRowHeight, fieldsData);
|
|
},
|
|
[expandedRows, avgCharWidth, defaultRowHeight, fieldsData, cellHeightCalc]
|
|
);
|
|
|
|
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
|
const target = event.currentTarget;
|
|
scrollPositionRef.current = {
|
|
x: target.scrollLeft,
|
|
y: target.scrollTop,
|
|
};
|
|
};
|
|
|
|
// Reset sortColumns when initialSortBy changes
|
|
useEffect(() => {
|
|
if (initialSortColumns.length > 0) {
|
|
setSortColumns(initialSortColumns);
|
|
}
|
|
}, [initialSortColumns]);
|
|
|
|
// Restore scroll position after re-renders
|
|
useEffect(() => {
|
|
if (tableRef.current?.element) {
|
|
tableRef.current.element.scrollLeft = scrollPositionRef.current.x;
|
|
tableRef.current.element.scrollTop = scrollPositionRef.current.y;
|
|
}
|
|
}, [revId]);
|
|
|
|
return (
|
|
<>
|
|
<DataGrid<TableRow, TableSummaryRow>
|
|
ref={tableRef}
|
|
className={styles.dataGrid}
|
|
// Default to true, overridden to false for testing
|
|
enableVirtualization={enableVirtualization}
|
|
key={`DataGrid${revId}`}
|
|
rows={enablePagination ? paginatedRows : sortedRows}
|
|
columns={columns}
|
|
headerRowHeight={noHeader ? 0 : undefined}
|
|
defaultColumnOptions={{
|
|
sortable: true,
|
|
resizable: true,
|
|
}}
|
|
rowHeight={textWrap || isNestedTable ? calculateRowHeight : defaultRowHeight}
|
|
// TODO: This doesn't follow current table behavior
|
|
style={{ width, height: height - (enablePagination ? paginationHeight : 0) }}
|
|
renderers={{
|
|
renderRow: (key, props) =>
|
|
myRowRenderer(key, props, expandedRows, panelContext, data, enableSharedCrosshair ?? false),
|
|
}}
|
|
onScroll={handleScroll}
|
|
onCellContextMenu={({ row, column }, event) => {
|
|
event.preventGridDefault();
|
|
// Do not show the default context menu
|
|
event.preventDefault();
|
|
|
|
const cellValue = row[column.key];
|
|
setContextMenuProps({
|
|
// rowIdx: rows.indexOf(row),
|
|
value: String(cellValue ?? ''),
|
|
top: event.clientY,
|
|
left: event.clientX,
|
|
});
|
|
setIsContextMenuOpen(true);
|
|
}}
|
|
// sorting
|
|
sortColumns={sortColumns}
|
|
// footer
|
|
// TODO figure out exactly how this works - some array needs to be here for it to render regardless of renderSummaryCell()
|
|
bottomSummaryRows={isFooterVisible ? [{}] : undefined}
|
|
onColumnResize={() => {
|
|
// NOTE: This method is called continuously during the column resize drag operation,
|
|
// providing the current column width. There is no separate event for the end of the drag operation.
|
|
if (textWrap) {
|
|
// This is needed only when textWrap is enabled
|
|
// TODO: this is a hack to force rowHeight re-calculation
|
|
setResizeTrigger((prev) => prev + 1);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{enablePagination && (
|
|
<div className={styles.paginationContainer} ref={paginationWrapperRef}>
|
|
<Pagination
|
|
className="table-ng-pagination"
|
|
currentPage={page + 1}
|
|
numberOfPages={numberOfPages}
|
|
showSmallVersion={smallPagination}
|
|
onNavigate={(toPage) => {
|
|
setPage(toPage - 1);
|
|
}}
|
|
/>
|
|
{!smallPagination && (
|
|
<div className={styles.paginationSummary}>
|
|
<Trans i18nKey="grafana-ui.table.pagination-summary">
|
|
{{ itemsRangeStart }} - {{ displayedEnd }} of {{ numRows }} rows
|
|
</Trans>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{isContextMenuOpen && (
|
|
<ContextMenu
|
|
x={contextMenuProps?.left || 0}
|
|
y={contextMenuProps?.top || 0}
|
|
renderMenuItems={renderMenuItems}
|
|
focusOnOpen={false}
|
|
/>
|
|
)}
|
|
|
|
{isInspecting && (
|
|
<TableCellInspector
|
|
mode={contextMenuProps?.mode ?? TableCellInspectorMode.text}
|
|
value={contextMenuProps?.value}
|
|
onDismiss={() => {
|
|
setIsInspecting(false);
|
|
setContextMenuProps(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function mapFrameToDataGrid({
|
|
frame,
|
|
calcsRef,
|
|
options,
|
|
handlers,
|
|
availableWidth,
|
|
}: {
|
|
frame: DataFrame;
|
|
calcsRef: React.MutableRefObject<string[]>;
|
|
options: MapFrameToGridOptions;
|
|
handlers: { onCellExpand: (rowIdx: number) => void; onColumnResize: TableColumnResizeActionCallback };
|
|
availableWidth: number;
|
|
}): TableColumn[] {
|
|
const {
|
|
columnTypes,
|
|
textWraps,
|
|
crossFilterOrder,
|
|
crossFilterRows,
|
|
defaultLineHeight,
|
|
defaultRowHeight,
|
|
expandedRows,
|
|
filter,
|
|
headerCellRefs,
|
|
isCountRowsSet,
|
|
onCellFilterAdded,
|
|
ctx,
|
|
onSortByChange,
|
|
rows,
|
|
sortedRows,
|
|
setContextMenuProps,
|
|
setFilter,
|
|
setIsInspecting,
|
|
setSortColumns,
|
|
sortColumnsRef,
|
|
styles,
|
|
theme,
|
|
timeRange,
|
|
getActions,
|
|
showTypeIcons,
|
|
replaceVariables,
|
|
} = options;
|
|
const { onCellExpand, onColumnResize } = handlers;
|
|
|
|
const columns: TableColumn[] = [];
|
|
const hasNestedFrames = getIsNestedTable(frame);
|
|
|
|
// If nested frames, add expansion control column
|
|
if (hasNestedFrames) {
|
|
const expanderField: Field = {
|
|
name: '',
|
|
type: FieldType.other,
|
|
config: {},
|
|
values: [],
|
|
};
|
|
columns.push({
|
|
key: 'expanded',
|
|
name: '',
|
|
field: expanderField,
|
|
cellClass: styles.cell,
|
|
colSpan(args) {
|
|
return args.type === 'ROW' && Number(args.row.__depth) === 1 ? frame.fields.length : 1;
|
|
},
|
|
renderCell: ({ row }) => {
|
|
// TODO add TableRow type extension to include row depth and optional data
|
|
if (Number(row.__depth) === 0) {
|
|
const rowIdx = Number(row.__index);
|
|
return (
|
|
<RowExpander
|
|
height={defaultRowHeight}
|
|
onCellExpand={() => onCellExpand(rowIdx)}
|
|
isExpanded={expandedRows.includes(rowIdx)}
|
|
/>
|
|
);
|
|
}
|
|
// If it's a child, render entire DataGrid at first column position
|
|
let expandedColumns: TableColumn[] = [];
|
|
let expandedRecords: TableRow[] = [];
|
|
|
|
// Type guard to check if data exists as it's optional
|
|
if (row.data) {
|
|
expandedColumns = mapFrameToDataGrid({
|
|
frame: row.data,
|
|
calcsRef,
|
|
options: { ...options },
|
|
handlers: { onCellExpand, onColumnResize },
|
|
availableWidth: availableWidth - COLUMN.EXPANDER_WIDTH,
|
|
});
|
|
expandedRecords = frameToRecords(row.data);
|
|
}
|
|
|
|
// TODO add renderHeaderCell HeaderCell's here and handle all features
|
|
return (
|
|
<DataGrid<TableRow, TableSummaryRow>
|
|
rows={expandedRecords}
|
|
columns={expandedColumns}
|
|
rowHeight={defaultRowHeight}
|
|
style={{ height: '100%', overflow: 'visible', marginLeft: COLUMN.EXPANDER_WIDTH }}
|
|
headerRowHeight={row.data?.meta?.custom?.noHeader ? 0 : undefined}
|
|
/>
|
|
);
|
|
},
|
|
width: COLUMN.EXPANDER_WIDTH,
|
|
minWidth: COLUMN.EXPANDER_WIDTH,
|
|
});
|
|
|
|
availableWidth -= COLUMN.EXPANDER_WIDTH;
|
|
}
|
|
|
|
// Row background color function
|
|
let rowBg: Function | undefined = undefined;
|
|
for (const field of frame.fields) {
|
|
const fieldOptions = field.config.custom;
|
|
const cellOptionsExist = fieldOptions !== undefined && fieldOptions.cellOptions !== undefined;
|
|
|
|
if (
|
|
cellOptionsExist &&
|
|
fieldOptions.cellOptions.type === TableCellDisplayMode.ColorBackground &&
|
|
fieldOptions.cellOptions.applyToRow
|
|
) {
|
|
rowBg = (rowIndex: number): CellColors => {
|
|
const display = field.display!(field.values[rowIndex]);
|
|
const colors = getCellColors(theme, fieldOptions.cellOptions, display);
|
|
return colors;
|
|
};
|
|
}
|
|
}
|
|
|
|
let fieldCountWithoutWidth = 0;
|
|
frame.fields.map((field, fieldIndex) => {
|
|
if (field.type === FieldType.nestedFrames || field.config.custom?.hidden) {
|
|
// Don't render nestedFrames type field
|
|
return;
|
|
}
|
|
const fieldTableOptions: TableFieldOptionsType = field.config.custom || {};
|
|
const key = field.name;
|
|
const justifyColumnContent = getTextAlign(field);
|
|
const footerStyles = getFooterStyles(justifyColumnContent);
|
|
|
|
// current/old table width logic calculations
|
|
if (fieldTableOptions.width) {
|
|
availableWidth -= fieldTableOptions.width;
|
|
} else {
|
|
fieldCountWithoutWidth++;
|
|
}
|
|
|
|
// Add a column for each field
|
|
columns.push({
|
|
key,
|
|
name: field.name,
|
|
field,
|
|
cellClass: textWraps[field.name] ? styles.cellWrapped : styles.cell,
|
|
renderCell: (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
|
|
const { row, rowIdx } = props;
|
|
const cellType = field.config?.custom?.cellOptions?.type ?? TableCellDisplayMode.Auto;
|
|
const value = row[key];
|
|
// Cell level rendering here
|
|
return (
|
|
<TableCellNG
|
|
frame={frame}
|
|
key={key}
|
|
value={value}
|
|
field={field}
|
|
theme={theme}
|
|
timeRange={timeRange ?? getDefaultTimeRange()}
|
|
height={defaultRowHeight}
|
|
justifyContent={justifyColumnContent}
|
|
rowIdx={sortedRows[rowIdx].__index}
|
|
shouldTextOverflow={() =>
|
|
shouldTextOverflow(
|
|
key,
|
|
row,
|
|
columnTypes,
|
|
headerCellRefs,
|
|
ctx,
|
|
defaultLineHeight,
|
|
defaultRowHeight,
|
|
TABLE.CELL_PADDING,
|
|
textWraps[field.name],
|
|
field,
|
|
cellType
|
|
)
|
|
}
|
|
setIsInspecting={setIsInspecting}
|
|
setContextMenuProps={setContextMenuProps}
|
|
getActions={getActions}
|
|
rowBg={rowBg}
|
|
onCellFilterAdded={onCellFilterAdded}
|
|
replaceVariables={replaceVariables}
|
|
/>
|
|
);
|
|
},
|
|
renderSummaryCell: () => {
|
|
if (isCountRowsSet && fieldIndex === 0) {
|
|
return (
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span>
|
|
<Trans i18nKey="grafana-ui.table.count">Count</Trans>
|
|
</span>
|
|
<span>{calcsRef.current[fieldIndex]}</span>
|
|
</div>
|
|
);
|
|
}
|
|
return <div className={footerStyles.footerCell}>{calcsRef.current[fieldIndex]}</div>;
|
|
},
|
|
renderHeaderCell: ({ column, sortDirection }): JSX.Element => (
|
|
<HeaderCell
|
|
column={column}
|
|
rows={rows}
|
|
field={field}
|
|
onSort={(columnKey, direction, isMultiSort) => {
|
|
handleSort(columnKey, direction, isMultiSort, setSortColumns, sortColumnsRef);
|
|
|
|
// Update panel context with the new sort order
|
|
if (onSortByChange) {
|
|
const sortByFields = sortColumnsRef.current.map(({ columnKey, direction }) => ({
|
|
displayName: columnKey,
|
|
desc: direction === 'DESC',
|
|
}));
|
|
onSortByChange(sortByFields);
|
|
}
|
|
}}
|
|
direction={sortDirection}
|
|
justifyContent={justifyColumnContent}
|
|
filter={filter}
|
|
setFilter={setFilter}
|
|
onColumnResize={onColumnResize}
|
|
headerCellRefs={headerCellRefs}
|
|
crossFilterOrder={crossFilterOrder}
|
|
crossFilterRows={crossFilterRows}
|
|
showTypeIcons={showTypeIcons}
|
|
/>
|
|
),
|
|
width: fieldTableOptions.width,
|
|
minWidth: fieldTableOptions.minWidth || COLUMN.DEFAULT_WIDTH,
|
|
});
|
|
});
|
|
|
|
// set columns that are at minimum width
|
|
let sharedWidth = availableWidth / fieldCountWithoutWidth;
|
|
for (let i = fieldCountWithoutWidth; i > 0; i--) {
|
|
for (const column of columns) {
|
|
if (!column.width && column.minWidth! > sharedWidth) {
|
|
column.width = column.minWidth;
|
|
availableWidth -= column.width!;
|
|
fieldCountWithoutWidth -= 1;
|
|
sharedWidth = availableWidth / fieldCountWithoutWidth;
|
|
}
|
|
}
|
|
}
|
|
|
|
// divide up the rest of the space
|
|
for (const column of columns) {
|
|
if (!column.width) {
|
|
column.width = sharedWidth;
|
|
}
|
|
column.minWidth = COLUMN.MIN_WIDTH;
|
|
}
|
|
|
|
return columns;
|
|
}
|
|
|
|
export function myRowRenderer(
|
|
key: React.Key,
|
|
props: RenderRowProps<TableRow, TableSummaryRow>,
|
|
expandedRows: number[],
|
|
panelContext: PanelContext,
|
|
data: DataFrame,
|
|
enableSharedCrosshair: boolean
|
|
): React.ReactNode {
|
|
// Let's render row level things here!
|
|
// i.e. we can look at row styles and such here
|
|
const { row } = props;
|
|
const rowIdx = Number(row.__index);
|
|
const isExpanded = expandedRows.includes(rowIdx);
|
|
|
|
// Don't render non expanded child rows
|
|
if (Number(row.__depth) === 1 && !isExpanded) {
|
|
return null;
|
|
}
|
|
|
|
// Add aria-expanded to parent rows that have nested data
|
|
if (row.data) {
|
|
return <Row key={key} {...props} aria-expanded={isExpanded} />;
|
|
}
|
|
|
|
return (
|
|
<Row
|
|
key={key}
|
|
{...props}
|
|
onMouseEnter={() => onRowHover(rowIdx, panelContext, data, enableSharedCrosshair)}
|
|
onMouseLeave={() => onRowLeave(panelContext, enableSharedCrosshair)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function onRowHover(idx: number, panelContext: PanelContext, frame: DataFrame, enableSharedCrosshair: boolean) {
|
|
if (!enableSharedCrosshair) {
|
|
return;
|
|
}
|
|
|
|
const timeField: Field = frame!.fields.find((f) => f.type === FieldType.time)!;
|
|
|
|
if (!timeField) {
|
|
return;
|
|
}
|
|
|
|
panelContext.eventBus.publish(
|
|
new DataHoverEvent({
|
|
point: {
|
|
time: timeField.values[idx],
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
export function onRowLeave(panelContext: PanelContext, enableSharedCrosshair: boolean) {
|
|
if (!enableSharedCrosshair) {
|
|
return;
|
|
}
|
|
|
|
panelContext.eventBus.publish(new DataHoverClearEvent());
|
|
}
|
|
|
|
const getStyles = (theme: GrafanaTheme2) => ({
|
|
dataGrid: css({
|
|
'--rdg-background-color': theme.colors.background.primary,
|
|
'--rdg-header-background-color': theme.colors.background.primary,
|
|
'--rdg-border-color': 'transparent',
|
|
'--rdg-color': theme.colors.text.primary,
|
|
'&:hover': {
|
|
'--rdg-row-hover-background-color': theme.colors.emphasize(theme.colors.action.hover, 0.6),
|
|
},
|
|
|
|
// If we rely solely on borderInlineEnd which is added from data grid, we
|
|
// get a small gap where the gridCell borders meet the column header borders.
|
|
// To avoid this, we can unset borderInlineEnd and set borderRight instead.
|
|
'.rdg-cell': {
|
|
borderInlineEnd: 'unset',
|
|
borderRight: `1px solid ${theme.colors.border.medium}`,
|
|
|
|
'&:last-child': {
|
|
borderRight: 'none',
|
|
},
|
|
},
|
|
|
|
'.rdg-summary-row': {
|
|
backgroundColor: theme.colors.background.primary,
|
|
'--rdg-summary-border-color': theme.colors.border.medium,
|
|
|
|
'.rdg-cell': {
|
|
// Prevent collisions with custom cell components
|
|
zIndex: 2,
|
|
borderRight: 'none',
|
|
},
|
|
},
|
|
|
|
// Due to stylistic choices, we do not want borders on the column headers
|
|
// other than the bottom border.
|
|
'div[role=columnheader]': {
|
|
borderBottom: `1px solid ${theme.colors.border.medium}`,
|
|
borderInlineEnd: 'unset',
|
|
|
|
'.r1y6ywlx7-0-0-beta-46': {
|
|
'&:hover': {
|
|
borderRight: `3px solid ${theme.colors.text.link}`,
|
|
},
|
|
},
|
|
},
|
|
|
|
'::-webkit-scrollbar': {
|
|
width: TABLE.SCROLL_BAR_WIDTH,
|
|
height: TABLE.SCROLL_BAR_WIDTH,
|
|
},
|
|
'::-webkit-scrollbar-thumb': {
|
|
backgroundColor: 'rgba(204, 204, 220, 0.16)',
|
|
borderRadius: '4px',
|
|
},
|
|
'::-webkit-scrollbar-track': {
|
|
background: 'transparent',
|
|
},
|
|
'::-webkit-scrollbar-corner': {
|
|
backgroundColor: 'transparent',
|
|
},
|
|
}),
|
|
menuItem: css({
|
|
maxWidth: '200px',
|
|
}),
|
|
cell: css({
|
|
'--rdg-border-color': theme.colors.border.medium,
|
|
borderLeft: 'none',
|
|
whiteSpace: 'nowrap',
|
|
wordWrap: 'break-word',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
|
|
// Reset default cell styles for custom cell component styling
|
|
paddingInline: '0',
|
|
}),
|
|
cellWrapped: css({
|
|
'--rdg-border-color': theme.colors.border.medium,
|
|
borderLeft: 'none',
|
|
whiteSpace: 'pre-line',
|
|
wordWrap: 'break-word',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
|
|
// Reset default cell styles for custom cell component styling
|
|
paddingInline: '0',
|
|
}),
|
|
paginationContainer: css({
|
|
alignItems: 'center',
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
marginTop: '8px',
|
|
width: '100%',
|
|
}),
|
|
paginationSummary: css({
|
|
color: theme.colors.text.secondary,
|
|
fontSize: theme.typography.bodySmall.fontSize,
|
|
display: 'flex',
|
|
justifyContent: 'flex-end',
|
|
padding: theme.spacing(0, 1, 0, 2),
|
|
}),
|
|
});
|
|
|