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.
891 lines
27 KiB
891 lines
27 KiB
import 'react-data-grid/lib/styles.css';
|
|
import { css, cx } from '@emotion/css';
|
|
import { Property } from 'csstype';
|
|
import { Key, ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Cell,
|
|
CellRendererProps,
|
|
DataGrid,
|
|
DataGridHandle,
|
|
DataGridProps,
|
|
RenderCellProps,
|
|
RenderRowProps,
|
|
Row,
|
|
SortColumn,
|
|
} from 'react-data-grid';
|
|
|
|
import {
|
|
DataHoverClearEvent,
|
|
DataHoverEvent,
|
|
FALLBACK_COLOR,
|
|
Field,
|
|
FieldType,
|
|
getDisplayProcessor,
|
|
GrafanaTheme2,
|
|
ReducerID,
|
|
} from '@grafana/data';
|
|
import { t, Trans } from '@grafana/i18n';
|
|
import { FieldColorModeId, TableCellHeight } from '@grafana/schema';
|
|
|
|
import { useStyles2, useTheme2 } from '../../../themes/ThemeContext';
|
|
import { ContextMenu } from '../../ContextMenu/ContextMenu';
|
|
import { MenuItem } from '../../Menu/MenuItem';
|
|
import { Pagination } from '../../Pagination/Pagination';
|
|
import { PanelContext, usePanelContext } from '../../PanelChrome';
|
|
import { DataLinksActionsTooltip } from '../DataLinksActionsTooltip';
|
|
import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspector';
|
|
import { CellColors, TableCellDisplayMode } from '../types';
|
|
import { DataLinksActionsTooltipState } from '../utils';
|
|
|
|
import { HeaderCell } from './Cells/HeaderCell';
|
|
import { RowExpander } from './Cells/RowExpander';
|
|
import { TableCellActions } from './Cells/TableCellActions';
|
|
import { getCellRenderer } from './Cells/renderers';
|
|
import { COLUMN, TABLE } from './constants';
|
|
import {
|
|
useColumnResize,
|
|
useFilteredRows,
|
|
useFooterCalcs,
|
|
useHeaderHeight,
|
|
usePaginatedRows,
|
|
useRowHeight,
|
|
useScrollbarWidth,
|
|
useSortedRows,
|
|
useTypographyCtx,
|
|
} from './hooks';
|
|
import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types';
|
|
import {
|
|
frameToRecords,
|
|
getDefaultRowHeight,
|
|
getDisplayName,
|
|
getIsNestedTable,
|
|
getTextAlign,
|
|
getVisibleFields,
|
|
shouldTextOverflow,
|
|
getApplyToRowBgFn,
|
|
computeColWidths,
|
|
applySort,
|
|
getCellColors,
|
|
getCellOptions,
|
|
shouldTextWrap,
|
|
isCellInspectEnabled,
|
|
getCellLinks,
|
|
withDataLinksActionsTooltip,
|
|
} from './utils';
|
|
|
|
type CellRootRenderer = (key: React.Key, props: CellRendererProps<TableRow, TableSummaryRow>) => React.ReactNode;
|
|
|
|
export function TableNG(props: TableNGProps) {
|
|
const {
|
|
cellHeight,
|
|
data,
|
|
enablePagination = false,
|
|
enableSharedCrosshair = false,
|
|
enableVirtualization,
|
|
footerOptions,
|
|
getActions = () => [],
|
|
height,
|
|
initialSortBy,
|
|
noHeader,
|
|
onCellFilterAdded,
|
|
onColumnResize,
|
|
onSortByChange,
|
|
showTypeIcons,
|
|
structureRev,
|
|
transparent,
|
|
width,
|
|
} = props;
|
|
|
|
const theme = useTheme2();
|
|
const styles = useStyles2(getGridStyles, {
|
|
enablePagination,
|
|
transparent,
|
|
});
|
|
const panelContext = usePanelContext();
|
|
|
|
const getCellActions = useCallback(
|
|
(field: Field, rowIdx: number) => getActions(data, field, rowIdx),
|
|
[getActions, data]
|
|
);
|
|
|
|
const hasHeader = !noHeader;
|
|
const hasFooter = Boolean(footerOptions?.show && footerOptions.reducer?.length);
|
|
const isCountRowsSet = Boolean(
|
|
footerOptions?.countRows &&
|
|
footerOptions.reducer &&
|
|
footerOptions.reducer.length &&
|
|
footerOptions.reducer[0] === ReducerID.count
|
|
);
|
|
|
|
const [contextMenuProps, setContextMenuProps] = useState<ContextMenuProps | null>(null);
|
|
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
|
|
|
const resizeHandler = useColumnResize(onColumnResize);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!isContextMenuOpen) {
|
|
return;
|
|
}
|
|
|
|
function onClick(_event: MouseEvent) {
|
|
setIsContextMenuOpen(false);
|
|
}
|
|
|
|
window.addEventListener('click', onClick);
|
|
|
|
return () => {
|
|
window.removeEventListener('click', onClick);
|
|
};
|
|
}, [isContextMenuOpen]);
|
|
|
|
const rows = useMemo(() => frameToRecords(data), [data]);
|
|
const hasNestedFrames = useMemo(() => getIsNestedTable(data.fields), [data]);
|
|
|
|
const {
|
|
rows: filteredRows,
|
|
filter,
|
|
setFilter,
|
|
crossFilterOrder,
|
|
crossFilterRows,
|
|
} = useFilteredRows(rows, data.fields, { hasNestedFrames });
|
|
|
|
const {
|
|
rows: sortedRows,
|
|
sortColumns,
|
|
setSortColumns,
|
|
} = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy });
|
|
|
|
const defaultRowHeight = getDefaultRowHeight(theme, cellHeight);
|
|
const defaultHeaderHeight = getDefaultRowHeight(theme, TableCellHeight.Sm);
|
|
const [isInspecting, setIsInspecting] = useState(false);
|
|
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});
|
|
|
|
// vt scrollbar accounting for column auto-sizing
|
|
const visibleFields = useMemo(() => getVisibleFields(data.fields), [data.fields]);
|
|
const gridRef = useRef<DataGridHandle>(null);
|
|
const scrollbarWidth = useScrollbarWidth(gridRef, height, sortedRows);
|
|
const availableWidth = useMemo(
|
|
() => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width) - scrollbarWidth,
|
|
[width, hasNestedFrames, scrollbarWidth]
|
|
);
|
|
const typographyCtx = useTypographyCtx();
|
|
const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]);
|
|
const headerHeight = useHeaderHeight({
|
|
columnWidths: widths,
|
|
fields: visibleFields,
|
|
enabled: hasHeader,
|
|
defaultHeight: defaultHeaderHeight,
|
|
sortColumns,
|
|
showTypeIcons: showTypeIcons ?? false,
|
|
typographyCtx,
|
|
});
|
|
const rowHeight = useRowHeight({
|
|
columnWidths: widths,
|
|
fields: visibleFields,
|
|
hasNestedFrames,
|
|
defaultHeight: defaultRowHeight,
|
|
headerHeight,
|
|
expandedRows,
|
|
typographyCtx,
|
|
});
|
|
|
|
const {
|
|
rows: paginatedRows,
|
|
page,
|
|
setPage,
|
|
numPages,
|
|
pageRangeStart,
|
|
pageRangeEnd,
|
|
smallPagination,
|
|
} = usePaginatedRows(sortedRows, {
|
|
enabled: enablePagination,
|
|
width: availableWidth,
|
|
height,
|
|
headerHeight,
|
|
footerHeight: hasFooter ? defaultRowHeight : 0,
|
|
rowHeight,
|
|
});
|
|
|
|
// Create a map of column key to text wrap
|
|
const footerCalcs = useFooterCalcs(sortedRows, data.fields, { enabled: hasFooter, footerOptions, isCountRowsSet });
|
|
const applyToRowBgFn = useMemo(() => getApplyToRowBgFn(data.fields, theme) ?? undefined, [data.fields, theme]);
|
|
|
|
const renderRow = useMemo(
|
|
() => renderRowFactory(data.fields, panelContext, expandedRows, enableSharedCrosshair),
|
|
[data, enableSharedCrosshair, expandedRows, panelContext]
|
|
);
|
|
|
|
const commonDataGridProps = useMemo(
|
|
() =>
|
|
({
|
|
enableVirtualization,
|
|
defaultColumnOptions: {
|
|
minWidth: 50,
|
|
resizable: true,
|
|
sortable: true,
|
|
// draggable: true,
|
|
},
|
|
onCellContextMenu: ({ row, column }, event) => {
|
|
// in nested tables, it's possible for this event to trigger in a column header
|
|
// when holding Ctrl for multi-row sort.
|
|
if (column.key === 'expanded') {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
},
|
|
onColumnResize: resizeHandler,
|
|
onSortColumnsChange: (newSortColumns: SortColumn[]) => {
|
|
setSortColumns(newSortColumns);
|
|
onSortByChange?.(
|
|
newSortColumns.map(({ columnKey, direction }) => ({
|
|
displayName: columnKey,
|
|
desc: direction === 'DESC',
|
|
}))
|
|
);
|
|
},
|
|
sortColumns,
|
|
rowHeight,
|
|
bottomSummaryRows: hasFooter ? [{}] : undefined,
|
|
}) satisfies Partial<DataGridProps<TableRow, TableSummaryRow>>,
|
|
[enableVirtualization, resizeHandler, sortColumns, rowHeight, hasFooter, setSortColumns, onSortByChange]
|
|
);
|
|
|
|
interface Schema {
|
|
columns: TableColumn[];
|
|
cellRootRenderers: Record<string, CellRootRenderer>;
|
|
colsWithTooltip: Record<string, boolean>;
|
|
}
|
|
|
|
const { columns, cellRootRenderers, colsWithTooltip } = useMemo(() => {
|
|
const fromFields = (f: Field[], widths: number[]) => {
|
|
const result: Schema = {
|
|
columns: [],
|
|
cellRootRenderers: {},
|
|
colsWithTooltip: {},
|
|
};
|
|
|
|
let lastRowIdx = -1;
|
|
let _rowHeight = 0;
|
|
|
|
f.forEach((field, i) => {
|
|
const cellOptions = getCellOptions(field);
|
|
const cellType = cellOptions.type;
|
|
|
|
// make sure we use mappings exclusively if they exist, ignore default thresholds mode
|
|
// we hack this by using the single color mode calculator
|
|
if (cellType === TableCellDisplayMode.Pill && (field.config.mappings?.length ?? 0 > 0)) {
|
|
field = {
|
|
...field,
|
|
config: {
|
|
...field.config,
|
|
color: {
|
|
...field.config.color,
|
|
mode: FieldColorModeId.Fixed,
|
|
fixedColor: field.config.color?.fixedColor ?? FALLBACK_COLOR,
|
|
},
|
|
},
|
|
};
|
|
field.display = getDisplayProcessor({ field, theme });
|
|
}
|
|
|
|
const justifyContent = getTextAlign(field);
|
|
const footerStyles = getFooterStyles(justifyContent);
|
|
const displayName = getDisplayName(field);
|
|
const headerCellClass = getHeaderCellStyles(theme, justifyContent).headerCell;
|
|
const renderFieldCell = getCellRenderer(field, cellOptions);
|
|
|
|
const cellInspect = isCellInspectEnabled(field);
|
|
const showFilters = Boolean(field.config.filterable && onCellFilterAdded != null);
|
|
const showActions = cellInspect || showFilters;
|
|
const width = widths[i];
|
|
|
|
// helps us avoid string cx and emotion per-cell
|
|
const cellActionClassName = showActions
|
|
? cx(
|
|
'table-cell-actions',
|
|
styles.cellActions,
|
|
justifyContent === 'flex-end' ? styles.cellActionsEnd : styles.cellActionsStart
|
|
)
|
|
: undefined;
|
|
|
|
const shouldOverflow = shouldTextOverflow(field);
|
|
const shouldWrap = shouldTextWrap(field);
|
|
const withTooltip = withDataLinksActionsTooltip(field, cellType);
|
|
|
|
result.colsWithTooltip[displayName] = withTooltip;
|
|
|
|
// this fires first
|
|
const renderCellRoot = (key: Key, props: CellRendererProps<TableRow, TableSummaryRow>): ReactNode => {
|
|
const rowIdx = props.row.__index;
|
|
const value = props.row[props.column.key];
|
|
|
|
// meh, this should be cached by the renderRow() call?
|
|
if (rowIdx !== lastRowIdx) {
|
|
_rowHeight = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
|
|
lastRowIdx = rowIdx;
|
|
}
|
|
|
|
let colors: CellColors;
|
|
|
|
if (applyToRowBgFn != null) {
|
|
colors = applyToRowBgFn(props.rowIdx);
|
|
} else if (cellType !== TableCellDisplayMode.Auto) {
|
|
const displayValue = field.display!(value); // this fires here to get colors, then again to get rendered value?
|
|
colors = getCellColors(theme, cellOptions, displayValue);
|
|
} else {
|
|
colors = {};
|
|
}
|
|
|
|
const cellStyle = getCellStyles(theme, field, _rowHeight, shouldWrap, shouldOverflow, withTooltip, colors);
|
|
|
|
return (
|
|
<Cell
|
|
key={key}
|
|
{...props}
|
|
className={cx(props.className, cellStyle.cell)}
|
|
style={{ color: colors.textColor ?? 'inherit' }}
|
|
/>
|
|
);
|
|
};
|
|
|
|
result.cellRootRenderers[displayName] = renderCellRoot;
|
|
|
|
// this fires second
|
|
const renderCellContent = (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
|
|
const rowIdx = props.row.__index;
|
|
const value = props.row[props.column.key];
|
|
const frame = data;
|
|
|
|
return (
|
|
<>
|
|
{renderFieldCell({
|
|
cellOptions,
|
|
frame,
|
|
field,
|
|
height: _rowHeight,
|
|
justifyContent,
|
|
rowIdx,
|
|
theme,
|
|
value,
|
|
width,
|
|
cellInspect,
|
|
showFilters,
|
|
getActions: getCellActions,
|
|
})}
|
|
{showActions && (
|
|
<TableCellActions
|
|
field={field}
|
|
value={value}
|
|
cellOptions={cellOptions}
|
|
displayName={displayName}
|
|
cellInspect={cellInspect}
|
|
showFilters={showFilters}
|
|
className={cellActionClassName}
|
|
setIsInspecting={setIsInspecting}
|
|
setContextMenuProps={setContextMenuProps}
|
|
onCellFilterAdded={onCellFilterAdded}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const column: TableColumn = {
|
|
field,
|
|
key: displayName,
|
|
name: displayName,
|
|
width,
|
|
headerCellClass,
|
|
renderCell: renderCellContent,
|
|
renderHeaderCell: ({ column, sortDirection }) => (
|
|
<HeaderCell
|
|
column={column}
|
|
rows={rows}
|
|
field={field}
|
|
filter={filter}
|
|
setFilter={setFilter}
|
|
crossFilterOrder={crossFilterOrder}
|
|
crossFilterRows={crossFilterRows}
|
|
direction={sortDirection}
|
|
showTypeIcons={showTypeIcons}
|
|
/>
|
|
),
|
|
renderSummaryCell: () => {
|
|
if (isCountRowsSet && i === 0) {
|
|
return (
|
|
<div className={footerStyles.footerCellCountRows}>
|
|
<span>
|
|
<Trans i18nKey="grafana-ui.table.count">Count</Trans>
|
|
</span>
|
|
<span>{footerCalcs[i]}</span>
|
|
</div>
|
|
);
|
|
}
|
|
return <div className={footerStyles.footerCell}>{footerCalcs[i]}</div>;
|
|
},
|
|
};
|
|
|
|
result.columns.push(column);
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
const result = fromFields(visibleFields, widths);
|
|
|
|
// handle nested frames rendering from here.
|
|
if (!hasNestedFrames) {
|
|
return result;
|
|
}
|
|
|
|
// pre-calculate renderRow and expandedColumns based on the first nested frame's fields.
|
|
const firstNestedData = rows.find((r) => r.data)?.data;
|
|
if (!firstNestedData) {
|
|
return result;
|
|
}
|
|
|
|
const hasNestedHeaders = firstNestedData.meta?.custom?.noHeader !== true;
|
|
const renderRow = renderRowFactory(firstNestedData.fields, panelContext, expandedRows, enableSharedCrosshair);
|
|
const { columns: nestedColumns, cellRootRenderers: nestedCellRootRenderers } = fromFields(
|
|
firstNestedData.fields,
|
|
computeColWidths(firstNestedData.fields, availableWidth)
|
|
);
|
|
|
|
const renderCellRoot: CellRootRenderer = (key, props) => nestedCellRootRenderers[props.column.key](key, props);
|
|
|
|
result.cellRootRenderers.expanded = (key, props) => <Cell key={key} {...props} />;
|
|
|
|
// If we have nested frames, we need to add a column for the row expansion
|
|
result.columns.unshift({
|
|
key: 'expanded',
|
|
name: '',
|
|
field: {
|
|
name: '',
|
|
type: FieldType.other,
|
|
config: {},
|
|
values: [],
|
|
},
|
|
cellClass(row) {
|
|
if (row.__depth !== 0) {
|
|
return styles.cellNested;
|
|
}
|
|
return;
|
|
},
|
|
colSpan(args) {
|
|
return args.type === 'ROW' && args.row.__depth === 1 ? data.fields.length : 1;
|
|
},
|
|
renderCell: ({ row }) => {
|
|
if (row.__depth === 0) {
|
|
return (
|
|
<RowExpander
|
|
height={defaultRowHeight}
|
|
isExpanded={expandedRows[row.__index] ?? false}
|
|
onCellExpand={() => {
|
|
setExpandedRows({ ...expandedRows, [row.__index]: !expandedRows[row.__index] });
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Type guard to check if data exists as it's optional
|
|
const nestedData = row.data;
|
|
if (!nestedData) {
|
|
return null;
|
|
}
|
|
|
|
const expandedRecords = applySort(frameToRecords(nestedData), nestedData.fields, sortColumns);
|
|
if (!expandedRecords.length) {
|
|
return (
|
|
<div className={styles.noDataNested}>
|
|
<Trans i18nKey="grafana-ui.table.nested-table.no-data">No data</Trans>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DataGrid<TableRow, TableSummaryRow>
|
|
{...commonDataGridProps}
|
|
className={cx(styles.grid, styles.gridNested)}
|
|
headerRowClass={cx(styles.headerRow, { [styles.displayNone]: !hasNestedHeaders })}
|
|
headerRowHeight={hasNestedHeaders ? defaultHeaderHeight : 0}
|
|
columns={nestedColumns}
|
|
rows={expandedRecords}
|
|
renderers={{ renderRow, renderCell: renderCellRoot }}
|
|
/>
|
|
);
|
|
},
|
|
width: COLUMN.EXPANDER_WIDTH,
|
|
minWidth: COLUMN.EXPANDER_WIDTH,
|
|
});
|
|
|
|
return result;
|
|
}, [
|
|
applyToRowBgFn,
|
|
availableWidth,
|
|
commonDataGridProps,
|
|
crossFilterOrder,
|
|
crossFilterRows,
|
|
data,
|
|
defaultHeaderHeight,
|
|
defaultRowHeight,
|
|
enableSharedCrosshair,
|
|
expandedRows,
|
|
filter,
|
|
footerCalcs,
|
|
hasNestedFrames,
|
|
isCountRowsSet,
|
|
onCellFilterAdded,
|
|
panelContext,
|
|
rowHeight,
|
|
rows,
|
|
setFilter,
|
|
showTypeIcons,
|
|
sortColumns,
|
|
styles,
|
|
theme,
|
|
visibleFields,
|
|
widths,
|
|
getCellActions,
|
|
]);
|
|
|
|
// invalidate columns on every structureRev change. this supports width editing in the fieldConfig.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const structureRevColumns = useMemo(() => columns, [columns, structureRev]);
|
|
|
|
// we need to have variables with these exact names for the localization to work properly
|
|
const itemsRangeStart = pageRangeStart;
|
|
const displayedEnd = pageRangeEnd;
|
|
const numRows = sortedRows.length;
|
|
|
|
const renderCellRoot: CellRootRenderer = (key, props) => {
|
|
return cellRootRenderers[props.column.key](key, props);
|
|
};
|
|
|
|
const [tooltipState, setTooltipState] = useState<DataLinksActionsTooltipState>();
|
|
|
|
return (
|
|
<>
|
|
<DataGrid<TableRow, TableSummaryRow>
|
|
{...commonDataGridProps}
|
|
ref={gridRef}
|
|
className={styles.grid}
|
|
columns={structureRevColumns}
|
|
rows={paginatedRows}
|
|
headerRowClass={cx(styles.headerRow, { [styles.displayNone]: noHeader })}
|
|
headerRowHeight={headerHeight}
|
|
onCellClick={({ column, row }, { clientX, clientY, preventGridDefault }) => {
|
|
// Note: could be column.field; JS says yes, but TS says no!
|
|
const field = columns[column.idx].field;
|
|
|
|
if (colsWithTooltip[getDisplayName(field)]) {
|
|
const rowIdx = row.__index;
|
|
setTooltipState({
|
|
coords: {
|
|
clientX,
|
|
clientY,
|
|
},
|
|
links: getCellLinks(field, rowIdx),
|
|
actions: getCellActions(field, rowIdx),
|
|
});
|
|
|
|
preventGridDefault();
|
|
}
|
|
}}
|
|
onCellKeyDown={
|
|
hasNestedFrames
|
|
? (_, event) => {
|
|
if (event.isDefaultPrevented()) {
|
|
// skip parent grid keyboard navigation if nested grid handled it
|
|
event.preventGridDefault();
|
|
}
|
|
}
|
|
: null
|
|
}
|
|
renderers={{ renderRow, renderCell: renderCellRoot }}
|
|
/>
|
|
|
|
{enablePagination && (
|
|
<div className={styles.paginationContainer}>
|
|
<Pagination
|
|
className="table-ng-pagination"
|
|
currentPage={page + 1}
|
|
numberOfPages={numPages}
|
|
showSmallVersion={smallPagination}
|
|
onNavigate={(toPage) => {
|
|
setPage(toPage - 1);
|
|
}}
|
|
/>
|
|
{!smallPagination && (
|
|
<div className={styles.paginationSummary}>
|
|
{/* TODO: once TableRT is deprecated, we can update the localiziation
|
|
string with the more consistent variable names */}
|
|
<Trans i18nKey="grafana-ui.table.pagination-summary">
|
|
{{ itemsRangeStart }} - {{ displayedEnd }} of {{ numRows }} rows
|
|
</Trans>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tooltipState && (
|
|
<DataLinksActionsTooltip
|
|
links={tooltipState.links ?? []}
|
|
actions={tooltipState.actions}
|
|
coords={tooltipState.coords}
|
|
onTooltipClose={() => setTooltipState(undefined)}
|
|
/>
|
|
)}
|
|
|
|
{isContextMenuOpen && (
|
|
<ContextMenu
|
|
x={contextMenuProps?.left || 0}
|
|
y={contextMenuProps?.top || 0}
|
|
renderMenuItems={() => (
|
|
<MenuItem
|
|
label={t('grafana-ui.table.inspect-menu-label', 'Inspect value')}
|
|
onClick={() => setIsInspecting(true)}
|
|
className={styles.menuItem}
|
|
/>
|
|
)}
|
|
focusOnOpen={false}
|
|
/>
|
|
)}
|
|
|
|
{isInspecting && (
|
|
<TableCellInspector
|
|
mode={contextMenuProps?.mode ?? TableCellInspectorMode.text}
|
|
value={contextMenuProps?.value}
|
|
onDismiss={() => {
|
|
setIsInspecting(false);
|
|
setContextMenuProps(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* this is passed to the top-level `renderRow` prop on DataGrid. applies aria attributes and custom event handlers.
|
|
*/
|
|
const renderRowFactory =
|
|
(
|
|
fields: Field[],
|
|
panelContext: PanelContext,
|
|
expandedRows: Record<string, boolean>,
|
|
enableSharedCrosshair: boolean
|
|
) =>
|
|
(key: React.Key, props: RenderRowProps<TableRow, TableSummaryRow>): React.ReactNode => {
|
|
const { row } = props;
|
|
const rowIdx = row.__index;
|
|
const isExpanded = !!expandedRows[rowIdx];
|
|
|
|
// Don't render non expanded child rows
|
|
if (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} />;
|
|
}
|
|
|
|
const handlers: Partial<typeof props> = {};
|
|
if (enableSharedCrosshair) {
|
|
const timeField = fields.find((f) => f.type === FieldType.time);
|
|
if (timeField) {
|
|
handlers.onMouseEnter = () => {
|
|
panelContext.eventBus.publish(
|
|
new DataHoverEvent({
|
|
point: {
|
|
time: timeField?.values[rowIdx],
|
|
},
|
|
})
|
|
);
|
|
};
|
|
handlers.onMouseLeave = () => {
|
|
panelContext.eventBus.publish(new DataHoverClearEvent());
|
|
};
|
|
}
|
|
}
|
|
|
|
return <Row key={key} {...props} {...handlers} />;
|
|
};
|
|
|
|
const getGridStyles = (
|
|
theme: GrafanaTheme2,
|
|
{ enablePagination, transparent }: { enablePagination?: boolean; transparent?: boolean }
|
|
) => ({
|
|
grid: css({
|
|
'--rdg-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary,
|
|
'--rdg-header-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary,
|
|
'--rdg-border-color': theme.colors.border.weak,
|
|
'--rdg-color': theme.colors.text.primary,
|
|
|
|
// note: this cannot have any transparency since default cells that
|
|
// overlay/overflow on hover inherit this background and need to occlude cells below
|
|
'--rdg-row-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary,
|
|
'--rdg-row-hover-background-color': transparent
|
|
? theme.colors.background.primary
|
|
: theme.colors.background.secondary,
|
|
|
|
// TODO: magic 32px number is unfortunate. it would be better to have the content
|
|
// flow using flexbox rather than hard-coding this size via a calc
|
|
blockSize: enablePagination ? 'calc(100% - 32px)' : '100%',
|
|
scrollbarWidth: 'thin',
|
|
scrollbarColor: theme.isDark ? '#fff5 #fff1' : '#0005 #0001',
|
|
|
|
border: 'none',
|
|
|
|
'.rdg-summary-row': {
|
|
'.rdg-cell': {
|
|
zIndex: theme.zIndex.tooltip - 1,
|
|
paddingInline: TABLE.CELL_PADDING,
|
|
paddingBlock: TABLE.CELL_PADDING,
|
|
},
|
|
},
|
|
}),
|
|
gridNested: css({
|
|
height: '100%',
|
|
width: `calc(100% - ${COLUMN.EXPANDER_WIDTH - TABLE.CELL_PADDING * 2 - 1}px)`,
|
|
overflow: 'visible',
|
|
marginLeft: COLUMN.EXPANDER_WIDTH - TABLE.CELL_PADDING - 1,
|
|
marginBlock: TABLE.CELL_PADDING,
|
|
}),
|
|
cellNested: css({
|
|
'&[aria-selected=true]': {
|
|
outline: 'none',
|
|
},
|
|
}),
|
|
noDataNested: css({
|
|
height: TABLE.NESTED_NO_DATA_HEIGHT,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: theme.colors.text.secondary,
|
|
fontSize: theme.typography.h4.fontSize,
|
|
}),
|
|
cellActions: css({
|
|
display: 'none',
|
|
position: 'absolute',
|
|
top: 0,
|
|
margin: 'auto',
|
|
height: '100%',
|
|
color: theme.colors.text.primary,
|
|
background: theme.isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',
|
|
padding: theme.spacing.x0_5,
|
|
paddingInlineStart: theme.spacing.x1,
|
|
}),
|
|
cellActionsEnd: css({
|
|
left: 0,
|
|
}),
|
|
cellActionsStart: css({
|
|
right: 0,
|
|
}),
|
|
headerRow: css({
|
|
paddingBlockStart: 0,
|
|
fontWeight: 'normal',
|
|
'& .rdg-cell': {
|
|
height: '100%',
|
|
alignItems: 'flex-end',
|
|
},
|
|
}),
|
|
displayNone: css({
|
|
display: 'none',
|
|
}),
|
|
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),
|
|
}),
|
|
menuItem: css({
|
|
maxWidth: '200px',
|
|
}),
|
|
});
|
|
|
|
const getFooterStyles = (justifyContent: Property.JustifyContent) => ({
|
|
footerCellCountRows: css({
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
}),
|
|
footerCell: css({
|
|
display: 'flex',
|
|
justifyContent: justifyContent || 'space-between',
|
|
}),
|
|
});
|
|
|
|
const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => ({
|
|
headerCell: css({
|
|
display: 'flex',
|
|
gap: theme.spacing(0.5),
|
|
zIndex: theme.zIndex.tooltip - 1,
|
|
paddingInline: TABLE.CELL_PADDING,
|
|
paddingBlockEnd: TABLE.CELL_PADDING,
|
|
justifyContent,
|
|
'&:last-child': {
|
|
borderInlineEnd: 'none',
|
|
},
|
|
}),
|
|
});
|
|
|
|
const getCellStyles = (
|
|
theme: GrafanaTheme2,
|
|
field: Field,
|
|
rowHeight: number,
|
|
shouldWrap: boolean,
|
|
shouldOverflow: boolean,
|
|
hasTooltip: boolean,
|
|
colors: CellColors
|
|
) => {
|
|
return {
|
|
cell: css({
|
|
textOverflow: 'initial',
|
|
background: colors.bgColor ?? 'inherit',
|
|
alignContent: 'center',
|
|
justifyContent: getTextAlign(field),
|
|
paddingInline: TABLE.CELL_PADDING,
|
|
height: '100%',
|
|
minHeight: rowHeight, // min height interacts with the fit-content property on the overflow container
|
|
...(shouldWrap && { whiteSpace: 'pre-line' }),
|
|
...(hasTooltip && { cursor: 'pointer' }),
|
|
'&:last-child': {
|
|
borderInlineEnd: 'none',
|
|
},
|
|
'&:hover': {
|
|
background: colors.bgHoverColor,
|
|
'.table-cell-actions': {
|
|
display: 'flex',
|
|
},
|
|
...(shouldOverflow && {
|
|
zIndex: theme.zIndex.tooltip - 2,
|
|
whiteSpace: 'pre-line',
|
|
height: 'fit-content',
|
|
minWidth: 'fit-content',
|
|
paddingBlock: (rowHeight - TABLE.LINE_HEIGHT) / 2 - 1,
|
|
}),
|
|
},
|
|
}),
|
|
};
|
|
};
|
|
|