TableNG: Markdown cell, plus auto row height (#107549)

* TableNG: Markdown cell, plus custom row height

* tab indentation in cue file

* fix i18n

* trying an auto height with the updated RDG

* get auto cellHeight working

* i18n updates

* hoor disable_sanitize_html flag in MarkdownCell

* update react-data-grid version to attempt to support page up and down

* removing custom height

* use the latest experimental RDG with paging up and down

* TableNG: Wrap text for DataLinks and Pills; groundwork for max wrap length

* disable editing max wrapped lines for now

* disable wrap text line limit e2e

* new i18n extract after commenting out input

* wip

* kill max wrapped lines for now

* more cleanup

* remove targeting classes added for max wrapped lines

* fix Pill test

* couple more style cleanups

* fix e2es given these updates

* add a couple tests

* wip: tests

* add tests

* bump up capital letters in lorem ipsum

* fix copy-pasta mistake

* whoops, mis-merged the selector

* use a local count instead of getCellLinks

* use react-data-grid on react-18 branch

* fix linting on test

* gdev dashboard and smoketest for Markdown table

* remove cellHeightCustom

* reorganize in light of recent and upcoming changes

* remove one more reference to cellHeightCustom

* put getDefaultRowHeight back into a util

* clean up test

* swap cell height back to a radio

* revert ImageCell change, we'll do it in the getStyles PR

* don't memo defaultRowHeight

* final couple of style cleanups

* different approach to managing the auto height part of this

* kill console.log

* update i18n

* reorganized once more

* i18n

* guard against rowHeight being auto for virtualization

* may as well memoize the defaultRowHeight

* get rid of the enableVirtualization initializer thing

* fixes from CI

* fix test

* fix test

* just omit third arg for that test

* remove nonsensical test case

* this file didn't get re-gen'd

* fixes from review

* row expander doesn't need height

* remove console.log

* fix e2e after we fixed pagination toggle bug
This commit is contained in:
Paul Marbach
2025-08-01 19:56:12 -04:00
committed by GitHub
parent cbf256120e
commit a5ceac4474
22 changed files with 394 additions and 58 deletions
+3
View File
@@ -3932,6 +3932,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"]
],
"public/app/plugins/panel/table/table-new/cells/MarkdownCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/plugins/panel/table/table-new/cells/SparklineCellOptionsEditor.tsx:5381": [
[0, 0, 0, "\'VerticalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"]
@@ -0,0 +1,126 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 1735,
"links": [],
"panels": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false,
"wrapHeaderText": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "markdown"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"type": "markdown",
"dynamicHeight": true
}
}
]
}
]
},
"gridPos": {
"h": 18,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"cellHeight": "lg",
"footer": {
"enablePagination": true,
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"csvContent": "id,markdown\n1,\"<dl>\n <dt>Definition list</dt>\n <dd>Is something people use sometimes.</dd>\n <dt>Markdown in HTML</dt>\n <dd>Does *not* work **very** well. Use HTML <em onclick=\"\"alert('hi');\"\">tags</em>.</dd>\n</dl>\"\n2,\"Three or more...\n\n---\n\nHyphens\n\n***\n\nAsterisks\n\n___\n\nUnderscores\"\n3,\"Here's a line for us to start with.\n\nThis line is separated from the one above by two newlines, so it will be a *separate paragraph*.\n\nThis line is also a separate paragraph, but...\nThis line is only separated by a single newline, so it's a separate line in the *same paragraph*.\"\n4,\"<span style='color:red'>red</span>, <span style='color:green'>green</span>, <span style='color:blue'>blue</span>\"\n5,\"<img src='https://placecats.com/bella/300/180'>\"\n6,\"[Link](https://grafana.com), or <a href=\"\"https://grafana.com\"\" target=\"\"_blank\"\" ref=\"\"noopener noreferrer\"\">HTML link</a>\"\n7,\"1. foo\n1. bar\n - baz\n * bim\n3. bip\"\n8,\"# heading 1\n## heading 2\n### heading 3\n#### heading 4\n##### heading 5\n###### heading 6\"\n9,\"Emphasis, aka italics, with *asterisks* or _underscores_.\n\nStrong emphasis, aka bold, with **asterisks** or __underscores__.\n\nCombined emphasis with **asterisks and _underscores_**.\n\nStrikethrough uses two tildes. ~~Scratch this.~~\n\n<u>underline does require an HTML element tho.</u>\"\n10,\"```javascript\nvar s = 'JavaScript syntax highlighting';\nalert(s);\n```\"\n11,\"<style>thead { border-bottom: 1px solid lightgray; } td, th { padding: 6px; }</style>\n| Month | Savings |\n| -------- | ------- |\n| I heard | $250 |\n| you like | $80 |\n| tables | $365 |\"\n",
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"refId": "A",
"scenarioId": "csv_content"
}
],
"title": "Markdown Table",
"type": "table"
}
],
"preload": false,
"schemaVersion": 41,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Panel Tests - Table - Markdown",
"uid": "2769f5d8-0094-4ac4-a4f0-f68f620339cc",
"version": 1
}
+1
View File
@@ -94,6 +94,7 @@
"shared_queries": (import '../dev-dashboards/panel-common/shared_queries.json'),
"slow_queries_and_annotations": (import '../dev-dashboards/scenarios/slow_queries_and_annotations.json'),
"table_kitchen_sink": (import '../dev-dashboards/panel-table/table_kitchen_sink.json'),
"table_markdown": (import '../dev-dashboards/panel-table/table_markdown.json'),
"table_pagination": (import '../dev-dashboards/panel-table/table_pagination.json'),
"table_sparkline_cell": (import '../dev-dashboards/panel-table/table_sparkline_cell.json'),
"table_tests": (import '../dev-dashboards/panel-table/table_tests.json'),
@@ -225,7 +225,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
// in a second, though.
const smallRowStatus = await getRowStatus(page);
expect(smallRowStatus.end).toBeGreaterThan(1);
expect(page.getByRole('grid').getByRole('row')).toHaveCount(smallRowStatus.end + 1);
expect(page.getByRole('grid').getByRole('row')).toHaveCount(smallRowStatus.end + 2); // +2 for header and footer rows
// change cell height to Large
await dashboardPage
@@ -235,7 +235,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
.click();
const largeRowStatus = await getRowStatus(page);
expect(largeRowStatus.end).toBeLessThan(smallRowStatus.end);
expect(page.getByRole('grid').getByRole('row')).toHaveCount(largeRowStatus.end + 1);
expect(page.getByRole('grid').getByRole('row')).toHaveCount(largeRowStatus.end + 2); // +2 for header and footer rows
// click a page over with the directional nav
await page.getByLabel('next page').click();
@@ -0,0 +1,25 @@
import { test, expect } from '@grafana/plugin-e2e';
test.use({
viewport: { width: 1280, height: 1080 },
featureToggles: {
tableNextGen: true,
},
});
test.describe(
'Panels test: Table - Markdown',
{
tag: ['@panels', '@table'],
},
() => {
test('Tests Markdown tables are successfully rendered', async ({ gotoDashboardPage, page }) => {
await gotoDashboardPage({
uid: '2769f5d8-0094-4ac4-a4f0-f68f620339cc',
queryParams: new URLSearchParams({ editPanel: '1' }),
});
await expect(page.getByRole('grid')).toBeVisible();
});
}
);
@@ -708,6 +708,7 @@ export enum TableCellDisplayMode {
Image = 'image',
JSONView = 'json-view',
LcdGauge = 'lcd-gauge',
Markdown = 'markdown',
Pill = 'pill',
Sparkline = 'sparkline',
}
@@ -836,6 +837,11 @@ export interface TablePillCellOptions extends TableWrapTextOptions {
type: TableCellDisplayMode.Pill;
}
export interface TableMarkdownCellOptions {
dynamicHeight?: boolean;
type: TableCellDisplayMode.Markdown;
}
/**
* Height of a table cell
*/
@@ -850,7 +856,7 @@ export enum TableCellHeight {
* Table cell options. Each cell has a display mode
* and other potential options for that display.
*/
export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions);
export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions | TableMarkdownCellOptions);
/**
* Use UTC/GMT timezone
+7 -2
View File
@@ -4,7 +4,7 @@ package common
// in the table such as colored text, JSON, gauge, etc.
// The color-background-solid, gradient-gauge, and lcd-gauge
// modes are deprecated in favor of new cell subOptions
TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline" | "data-links" | "custom" | "actions" | "pill" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|DataLinks|Custom|Actions|Pill")
TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline" | "data-links" | "custom" | "actions" | "pill" | "markdown" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|DataLinks|Custom|Actions|Pill|Markdown")
// Display mode to the "Colored Background" display
// mode for table cells. Either displays a solid color (basic mode)
@@ -91,12 +91,17 @@ TablePillCellOptions: {
type: TableCellDisplayMode & "pill"
} & TableWrapTextOptions @cuetsy(kind="interface")
TableMarkdownCellOptions: {
type: TableCellDisplayMode & "markdown"
dynamicHeight?: bool
} @cuetsy(kind="interface")
// Height of a table cell
TableCellHeight: "sm" | "md" | "lg" | "auto" @cuetsy(kind="enum")
// Table cell options. Each cell has a display mode
// and other potential options for that display.
TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions @cuetsy(kind="type")
TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions | TableMarkdownCellOptions @cuetsy(kind="type")
// Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
// Generally defines alignment, filtering capabilties, display options, etc.
+1 -1
View File
@@ -109,7 +109,7 @@
"react-calendar": "^6.0.0",
"react-colorful": "5.6.1",
"react-custom-scrollbars-2": "4.5.0",
"react-data-grid": "grafana/react-data-grid#de920f0105cb2b7d774444e7443a675f3b568ad6",
"react-data-grid": "grafana/react-data-grid#a922856b5ede21d55db3fdffb6d38dc76bdc7c58",
"react-dropzone": "14.3.8",
"react-highlight-words": "0.21.0",
"react-hook-form": "^7.49.2",
@@ -0,0 +1,17 @@
import { renderMarkdown } from '@grafana/data';
import { MaybeWrapWithLink } from '../MaybeWrapWithLink';
import { MarkdownCellProps } from '../types';
export function MarkdownCell({ field, rowIdx, disableSanitizeHtml }: MarkdownCellProps) {
return (
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
<div
className="markdown-container"
dangerouslySetInnerHTML={{
__html: renderMarkdown(field.values[rowIdx], { noSanitize: disableSanitizeHtml }).trim(),
}}
/>
</MaybeWrapWithLink>
);
}
@@ -7,8 +7,8 @@ import { useStyles2 } from '../../../../themes/ThemeContext';
import { Icon } from '../../../Icon/Icon';
import { RowExpanderNGProps } from '../types';
export function RowExpander({ height, onCellExpand, isExpanded }: RowExpanderNGProps) {
const styles = useStyles2(getStyles, height);
export function RowExpander({ onCellExpand, isExpanded }: RowExpanderNGProps) {
const styles = useStyles2(getStyles);
function handleKeyDown(e: React.KeyboardEvent<HTMLSpanElement>) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
@@ -30,12 +30,11 @@ export function RowExpander({ height, onCellExpand, isExpanded }: RowExpanderNGP
);
}
const getStyles = (theme: GrafanaTheme2, rowHeight: number) => ({
const getStyles = (_theme: GrafanaTheme2) => ({
expanderCell: css({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
height: `${rowHeight}px`,
cursor: 'pointer',
}),
});
@@ -11,6 +11,7 @@ import { BarGaugeCell } from './BarGaugeCell';
import { DataLinksCell } from './DataLinksCell';
import { GeoCell } from './GeoCell';
import { ImageCell } from './ImageCell';
import { MarkdownCell } from './MarkdownCell';
import { PillCell } from './PillCell';
import { SparklineCell } from './SparklineCell';
@@ -78,6 +79,10 @@ const CUSTOM_RENDERER: TableCellRenderer = (props) => {
return <CustomCellComponent field={props.field} rowIndex={props.rowIdx} frame={props.frame} value={props.value} />;
};
const MARKDOWN_RENDERER: TableCellRenderer = (props) => (
<MarkdownCell field={props.field} rowIdx={props.rowIdx} disableSanitizeHtml={props.disableSanitizeHtml} />
);
const CELL_RENDERERS: Record<TableCellOptions['type'], TableCellRenderer> = {
[TableCellDisplayMode.Sparkline]: SPARKLINE_RENDERER,
[TableCellDisplayMode.Gauge]: GAUGE_RENDERER,
@@ -89,9 +94,16 @@ const CELL_RENDERERS: Record<TableCellOptions['type'], TableCellRenderer> = {
[TableCellDisplayMode.ColorText]: AUTO_RENDERER,
[TableCellDisplayMode.ColorBackground]: AUTO_RENDERER,
[TableCellDisplayMode.Auto]: AUTO_RENDERER,
[TableCellDisplayMode.Markdown]: MARKDOWN_RENDERER,
[TableCellDisplayMode.Pill]: PILL_RENDERER,
};
// TODO: come up with a more elegant way to handle this.
const STRING_ONLY_RENDERERS = new Set<TableCellOptions['type']>([
TableCellDisplayMode.Markdown,
TableCellDisplayMode.Pill,
]);
/** @internal */
export function getCellRenderer(field: Field, cellOptions: TableCellOptions): TableCellRenderer {
const cellType = cellOptions?.type ?? TableCellDisplayMode.Auto;
@@ -99,8 +111,7 @@ export function getCellRenderer(field: Field, cellOptions: TableCellOptions): Ta
return getAutoRendererResult(field);
}
// TODO: add support boolean, enum, (maybe int). but for now just string fields
if (cellType === TableCellDisplayMode.Pill && field.type !== FieldType.string) {
if (STRING_ONLY_RENDERERS.has(cellType) && field.type !== FieldType.string) {
return AUTO_RENDERER;
}
@@ -84,6 +84,7 @@ export function TableNG(props: TableNGProps) {
const {
cellHeight,
data,
disableSanitizeHtml,
enablePagination = false,
enableSharedCrosshair = false,
enableVirtualization,
@@ -160,12 +161,15 @@ export function TableNG(props: TableNGProps) {
setSortColumns,
} = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy });
const defaultRowHeight = getDefaultRowHeight(theme, cellHeight);
const [isInspecting, setIsInspecting] = useState(false);
const [expandedRows, setExpandedRows] = useState(() => new Set<number>());
// vt scrollbar accounting for column auto-sizing
const visibleFields = useMemo(() => getVisibleFields(data.fields), [data.fields]);
const defaultRowHeight = useMemo(
() => getDefaultRowHeight(theme, visibleFields, cellHeight),
[theme, visibleFields, cellHeight]
);
const gridRef = useRef<DataGridHandle>(null);
const scrollbarWidth = useScrollbarWidth(gridRef, height, sortedRows);
const availableWidth = useMemo(
@@ -212,7 +216,7 @@ export function TableNG(props: TableNGProps) {
width: availableWidth,
height,
headerHeight,
footerHeight: hasFooter ? defaultRowHeight : 0,
footerHeight: hasFooter ? (typeof defaultRowHeight === 'number' ? defaultRowHeight : TABLE.MAX_CELL_HEIGHT) : 0,
rowHeight,
});
@@ -224,6 +228,17 @@ export function TableNG(props: TableNGProps) {
});
const applyToRowBgFn = useMemo(() => getApplyToRowBgFn(data.fields, theme) ?? undefined, [data.fields, theme]);
// normalize the row height into a function which returns a number, so we avoid a bunch of conditionals during rendering.
const rowHeightFn = useMemo((): ((row: TableRow) => number) => {
if (typeof rowHeight === 'function') {
return rowHeight;
}
if (typeof rowHeight === 'string') {
return () => TABLE.MAX_CELL_HEIGHT;
}
return () => rowHeight;
}, [rowHeight]);
const renderRow = useMemo(
() => renderRowFactory(data.fields, panelContext, expandedRows, enableSharedCrosshair),
[data, enableSharedCrosshair, expandedRows, panelContext]
@@ -232,7 +247,7 @@ export function TableNG(props: TableNGProps) {
const commonDataGridProps = useMemo(
() =>
({
enableVirtualization,
enableVirtualization: enableVirtualization !== false && rowHeight !== 'auto',
defaultColumnOptions: {
minWidth: 50,
resizable: true,
@@ -348,8 +363,8 @@ export function TableNG(props: TableNGProps) {
)
: undefined;
const shouldOverflow = shouldTextOverflow(field);
const shouldWrap = shouldTextWrap(field);
const shouldOverflow = rowHeight !== 'auto' && shouldTextOverflow(field);
const shouldWrap = rowHeight === 'auto' || shouldTextWrap(field);
const withTooltip = withDataLinksActionsTooltip(field, cellType);
const canBeColorized =
cellType === TableCellDisplayMode.ColorBackground || cellType === TableCellDisplayMode.ColorText;
@@ -368,6 +383,7 @@ export function TableNG(props: TableNGProps) {
case TableCellDisplayMode.DataLinks:
case TableCellDisplayMode.JSONView:
case TableCellDisplayMode.Pill:
case TableCellDisplayMode.Markdown:
cellClass = getCellStyles(
theme,
cellType,
@@ -428,7 +444,9 @@ export function TableNG(props: TableNGProps) {
const value = props.row[props.column.key];
// TODO: it would be nice to get rid of passing height down as a prop. but this value
// is cached so the cost of calling for every cell is low.
const height = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
// NOTE: some cell types still require a height to be passed down, so that's why string-based
// cell types are going to just pass down the max cell height as a numeric height for those cells.
const height = rowHeightFn(props.row);
const frame = data;
return (
@@ -446,6 +464,7 @@ export function TableNG(props: TableNGProps) {
cellInspect,
showFilters,
getActions: getCellActions,
disableSanitizeHtml,
})}
{showActions && (
<TableCellActions
@@ -555,7 +574,6 @@ export function TableNG(props: TableNGProps) {
return (
<RowExpander
height={defaultRowHeight}
isExpanded={expandedRows.has(rowIdx)}
onCellExpand={() => {
if (expandedRows.has(rowIdx)) {
@@ -608,7 +626,7 @@ export function TableNG(props: TableNGProps) {
crossFilterOrder,
crossFilterRows,
data,
defaultRowHeight,
disableSanitizeHtml,
enableSharedCrosshair,
expandedRows,
filter,
@@ -618,6 +636,7 @@ export function TableNG(props: TableNGProps) {
onCellFilterAdded,
panelContext,
rowHeight,
rowHeightFn,
rows,
setFilter,
showTypeIcons,
@@ -947,16 +966,26 @@ const getCellStyles = (
shouldOverflow: boolean,
isColorized: boolean,
isMonospace: boolean
) =>
css({
) => {
const whiteSpace: CSSProperties['whiteSpace'] = (() => {
if (isMonospace) {
return 'pre';
}
if (cellType === TableCellDisplayMode.Markdown) {
return 'normal';
}
return 'pre-line';
})();
return css({
display: 'flex',
alignItems: 'center',
textAlign,
justifyContent: getJustifyContent(textAlign),
minHeight: '100%',
backgroundClip: 'padding-box !important', // helps when cells have a bg color
...(shouldWrap && { whiteSpace: isMonospace ? 'pre' : 'pre-line' }),
...(isColorized && { backgroundClip: 'padding-box !important' }),
...(shouldOverflow && { minHeight: '100%' }),
...(shouldWrap && { whiteSpace }),
...(isMonospace && { fontFamily: 'monospace' }),
'&:hover, &[aria-selected=true]': {
@@ -965,7 +994,7 @@ const getCellStyles = (
},
...(shouldOverflow && {
zIndex: theme.zIndex.tooltip - 2,
whiteSpace: isMonospace ? 'pre' : 'pre-line',
whiteSpace,
height: 'fit-content',
minWidth: 'fit-content',
...(cellType === TableCellDisplayMode.Pill && {
@@ -1025,4 +1054,22 @@ const getCellStyles = (
whiteSpace: 'nowrap',
},
}),
...(cellType === TableCellDisplayMode.Markdown && {
'& ol, & ul': {
paddingLeft: theme.spacing(1.5),
},
'& p': {
whiteSpace: 'pre-line',
},
'& a': {
color: theme.colors.primary.text,
},
// for elements like `p`, `h*`, etc. which have an inherent margin,
// we want to remove the bottom margin for the last one in the container.
'& > .markdown-container > *:last-child': {
marginBottom: 0,
},
}),
});
};
@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect, RefObject } from 'react';
import { useState, useMemo, useCallback, useRef, useLayoutEffect, RefObject, CSSProperties } from 'react';
import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-grid';
import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data';
@@ -135,7 +135,7 @@ export function useSortedRows(
export interface PaginatedRowsOptions {
height: number;
width: number;
rowHeight: number | ((row: TableRow) => number);
rowHeight: NonNullable<CSSProperties['height']> | ((row: TableRow) => number);
headerHeight: number;
footerHeight: number;
paginationHeight?: number;
@@ -174,6 +174,12 @@ export function usePaginatedRows(
return rowHeight;
}
// when using auto-sized rows, we're just going to have to pick a number. the alternative
// is to measure each row, which we could do but would be expensive.
if (typeof rowHeight === 'string') {
return TABLE.MAX_CELL_HEIGHT;
}
// we'll just measure 100 rows to estimate
return rows.slice(0, 100).reduce((avg, row, _, { length }) => avg + rowHeight(row) / length, 0);
}, [rows, rowHeight, enabled]);
@@ -214,7 +220,7 @@ export function usePaginatedRows(
}, [width, height, headerHeight, footerHeight, avgRowHeight, enabled, numRows, page]);
// safeguard against page overflow on panel resize or other factors
useEffect(() => {
useLayoutEffect(() => {
if (!enabled) {
return;
}
@@ -381,7 +387,7 @@ interface UseRowHeightOptions {
columnWidths: number[];
fields: Field[];
hasNestedFrames: boolean;
defaultHeight: number;
defaultHeight: NonNullable<CSSProperties['height']>;
expandedRows: Set<number>;
typographyCtx: TypographyCtx;
}
@@ -393,7 +399,7 @@ export function useRowHeight({
defaultHeight,
expandedRows,
typographyCtx,
}: UseRowHeightOptions): number | ((row: TableRow) => number) {
}: UseRowHeightOptions): NonNullable<CSSProperties['height']> | ((row: TableRow) => number) {
const lineCounters = useMemo(() => buildRowLineCounters(fields, typographyCtx), [fields, typographyCtx]);
const hasWrappedCols = useMemo(() => lineCounters?.length ?? 0 > 0, [lineCounters]);
@@ -404,7 +410,7 @@ export function useRowHeight({
const rowHeight = useMemo(() => {
// row height is only complicated when there are nested frames or wrapped columns.
if (!hasNestedFrames && !hasWrappedCols) {
if ((!hasNestedFrames && !hasWrappedCols) || typeof defaultHeight === 'string') {
return defaultHeight;
}
@@ -141,6 +141,8 @@ export interface BaseTableProps {
getActions?: GetActionsFunction;
// Used solely for testing as RTL can't correctly render the table otherwise
enableVirtualization?: boolean;
// for MarkdownCell, this flag disables sanitization of HTML content. Configured via config.ini.
disableSanitizeHtml?: boolean;
}
/* ---------------------------- Table cell props ---------------------------- */
@@ -161,6 +163,7 @@ export interface TableCellRendererProps {
showFilters: boolean;
justifyContent: Property.JustifyContent;
getActions?: GetActionsFunctionLocal;
disableSanitizeHtml?: boolean;
}
export type ContextMenuProps = {
@@ -186,7 +189,6 @@ export interface TableCellActionsProps {
/* ------------------------- Specialized Cell Props ------------------------- */
export interface RowExpanderNGProps {
height: number;
onCellExpand: (e: SyntheticEvent) => void;
isExpanded?: boolean;
}
@@ -242,6 +244,12 @@ export interface AutoCellProps {
rowIdx: number;
}
export interface MarkdownCellProps {
field: Field;
rowIdx: number;
disableSanitizeHtml?: boolean;
}
export interface ActionCellProps {
field: Field;
rowIdx: number;
@@ -27,7 +27,6 @@ import {
getCellLinks,
getCellOptions,
getComparator,
getDefaultRowHeight,
getIsNestedTable,
getAlignment,
getJustifyContent,
@@ -44,6 +43,7 @@ import {
wrapUwrapCount,
getDataLinksCounter,
getPillLineCounter,
getDefaultRowHeight,
} from './utils';
describe('TableNG utils', () => {
@@ -787,23 +787,46 @@ describe('TableNG utils', () => {
describe('getDefaultRowHeight', () => {
const theme = createTheme();
it('returns correct height for TableCellHeight.Sm', () => {
const result = getDefaultRowHeight(theme, TableCellHeight.Sm);
expect(result).toBe(36);
it.each([
{ input: TableCellHeight.Sm, expected: 36 },
{ input: TableCellHeight.Md, expected: 42 },
{ input: TableCellHeight.Lg, expected: TABLE.MAX_CELL_HEIGHT },
])('returns "$expected" for "$input"', ({ input, expected }) => {
const result = getDefaultRowHeight(theme, [], input);
expect(result).toBe(expected);
});
it('returns correct height for TableCellHeight.Md', () => {
const result = getDefaultRowHeight(theme, TableCellHeight.Md);
expect(result).toBe(42);
});
it('returns correct height for TableCellHeight.Lg', () => {
const result = getDefaultRowHeight(theme, TableCellHeight.Lg);
expect(result).toBe(TABLE.MAX_CELL_HEIGHT);
it('returns "auto" if a field is present with the dynamicHeight cellOption is false', () => {
expect(
getDefaultRowHeight(
theme,
[
{
name: 'test1',
type: FieldType.string,
config: {},
values: ['value1'],
},
{
name: 'test2',
type: FieldType.string,
config: { custom: { cellOptions: { type: TableCellDisplayMode.Markdown, dynamicHeight: true } } },
values: ['value1'],
},
{
name: 'test3',
type: FieldType.number,
config: { custom: { cellOptions: { type: TableCellDisplayMode.JSONView } } },
values: [3],
},
],
TableCellHeight.Sm
)
).toBe('auto');
});
it('calculates height based on theme when cellHeight is undefined', () => {
const result = getDefaultRowHeight(theme, undefined as unknown as TableCellHeight);
const result = getDefaultRowHeight(theme, []);
// Calculate the expected result based on the theme values
const expected = TABLE.CELL_PADDING * 2 + theme.typography.fontSize * theme.typography.body.lineHeight;
@@ -1,4 +1,5 @@
import { Property } from 'csstype';
import { CSSProperties } from 'react';
import { SortColumn } from 'react-data-grid';
import tinycolor from 'tinycolor2';
import { Count, varPreLine } from 'uwrap';
@@ -45,9 +46,14 @@ export type CellNumLinesCalculator = (text: string, cellWidth: number) => number
* @internal
* Returns the default row height based on the theme and cell height setting.
*/
export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight?: TableCellHeight): number {
const bodyFontSize = theme.typography.fontSize;
const lineHeight = theme.typography.body.lineHeight;
export function getDefaultRowHeight(
theme: GrafanaTheme2,
fields?: Field[],
cellHeight?: TableCellHeight
): NonNullable<CSSProperties['height']> {
if (fields?.some((field) => field.config?.custom?.cellOptions?.dynamicHeight)) {
return 'auto';
}
switch (cellHeight) {
case TableCellHeight.Sm:
@@ -58,7 +64,7 @@ export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight?: TableCell
return TABLE.MAX_CELL_HEIGHT;
}
return TABLE.CELL_PADDING * 2 + bodyFontSize * lineHeight;
return TABLE.CELL_PADDING * 2 + theme.typography.fontSize * theme.typography.body.lineHeight;
}
/**
@@ -4,11 +4,8 @@ import { StandardEditorProps } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Switch } from '@grafana/ui';
export function PaginationEditor({ onChange, value, context }: StandardEditorProps<boolean>) {
export function PaginationEditor({ onChange, value }: StandardEditorProps<boolean>) {
const changeValue = (event: React.FormEvent<HTMLInputElement> | undefined) => {
if (event?.currentTarget.checked) {
context.options.footer.show = false;
}
onChange(event?.currentTarget.checked);
};
@@ -10,6 +10,7 @@ import { Combobox, ComboboxOption, Field, TableCellDisplayMode, useStyles2 } fro
import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor';
import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor';
import { ImageCellOptionsEditor } from './cells/ImageCellOptionsEditor';
import { MarkdownCellOptionsEditor } from './cells/MarkdownCellOptionsEditor';
import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor';
import { TextWrapOptionsEditor } from './cells/TextWrapOptionsEditor';
@@ -54,6 +55,7 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {
{ value: TableCellDisplayMode.Sparkline, label: t('table.cell-types.sparkline', 'Sparkline') },
{ value: TableCellDisplayMode.JSONView, label: t('table.cell-types.json', 'JSON View') },
{ value: TableCellDisplayMode.Pill, label: t('table.cell-types.pill', 'Pill') },
{ value: TableCellDisplayMode.Markdown, label: t('table.cell-types.markdown', 'Markdown + HTML') },
{ value: TableCellDisplayMode.Image, label: t('table.cell-types.image', 'Image') },
{ value: TableCellDisplayMode.Actions, label: t('table.cell-types.actions', 'Actions') },
];
@@ -105,6 +107,9 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {
{cellType === TableCellDisplayMode.Image && (
<ImageCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
{cellType === TableCellDisplayMode.Markdown && (
<MarkdownCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
</div>
);
};
@@ -17,6 +17,7 @@ import { config, PanelDataErrorView } from '@grafana/runtime';
import { Select, usePanelContext, useTheme2 } from '@grafana/ui';
import { TableSortByFieldState } from '@grafana/ui/internal';
import { TableNG } from '@grafana/ui/unstable';
import { getConfig } from 'app/core/config';
import { getActions } from '../../../../features/actions/utils';
@@ -61,6 +62,8 @@ export function TablePanel(props: Props) {
const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off;
const disableSanitizeHtml = getConfig().disableSanitizeHtml;
const tableElement = (
<TableNG
height={tableHeight}
@@ -82,6 +85,7 @@ export function TablePanel(props: Props) {
getActions={_getActions}
structureRev={data.structureRev}
transparent={transparent}
disableSanitizeHtml={disableSanitizeHtml}
/>
);
@@ -0,0 +1,39 @@
import { FormEvent } from 'react';
import { t, Trans } from '@grafana/i18n';
import { TableMarkdownCellOptions } from '@grafana/schema';
import { Badge, Field, Label, Switch } from '@grafana/ui';
import { TableCellEditorProps } from '../TableCellOptionEditor';
export const MarkdownCellOptionsEditor = ({
cellOptions,
onChange,
}: TableCellEditorProps<TableMarkdownCellOptions>) => {
const onDynamicHeightChange = (e: FormEvent<HTMLInputElement>) => {
cellOptions.dynamicHeight = e.currentTarget.checked;
onChange(cellOptions);
};
return (
<Field
label={
<Label
description={t(
'table.markdown-cell-options-editor.description-dynamic-height',
'We recommend enabling pagination with this option to avoid performance issues.'
)}
>
<Trans i18nKey="table.markdown-cell-options-editor.label-dynamic-height">Dynamic height</Trans>{' '}
<Badge
text={t('table.markdown-cell-options-editor.label.text-alpha', 'Alpha')}
color="blue"
style={{ fontSize: '11px', marginLeft: '5px', lineHeight: '1.2' }}
/>
</Label>
}
>
<Switch onChange={onDynamicHeightChange} value={cellOptions.dynamicHeight} />
</Field>
);
};
+8
View File
@@ -12529,6 +12529,7 @@
"gauge": "Gauge",
"image": "Image",
"json": "JSON View",
"markdown": "Markdown + HTML",
"pill": "Pill",
"sparkline": "Sparkline"
},
@@ -12566,6 +12567,13 @@
"label-alt-text": "Alt text",
"label-title-text": "Title text"
},
"markdown-cell-options-editor": {
"description-dynamic-height": "We recommend enabling pagination with this option to avoid performance issues.",
"label": {
"text-alpha": "Alpha"
},
"label-dynamic-height": "Dynamic height"
},
"name-calculation": "Calculation",
"name-cell-height": "Cell height",
"name-cell-type": "Cell type",
+4 -4
View File
@@ -3829,7 +3829,7 @@ __metadata:
react-calendar: "npm:^6.0.0"
react-colorful: "npm:5.6.1"
react-custom-scrollbars-2: "npm:4.5.0"
react-data-grid: "grafana/react-data-grid#de920f0105cb2b7d774444e7443a675f3b568ad6"
react-data-grid: "grafana/react-data-grid#a922856b5ede21d55db3fdffb6d38dc76bdc7c58"
react-dom: "npm:18.3.1"
react-dropzone: "npm:14.3.8"
react-highlight-words: "npm:0.21.0"
@@ -26823,15 +26823,15 @@ __metadata:
languageName: node
linkType: hard
"react-data-grid@grafana/react-data-grid#de920f0105cb2b7d774444e7443a675f3b568ad6":
"react-data-grid@grafana/react-data-grid#a922856b5ede21d55db3fdffb6d38dc76bdc7c58":
version: 7.0.0-beta.56
resolution: "react-data-grid@https://github.com/grafana/react-data-grid.git#commit=de920f0105cb2b7d774444e7443a675f3b568ad6"
resolution: "react-data-grid@https://github.com/grafana/react-data-grid.git#commit=a922856b5ede21d55db3fdffb6d38dc76bdc7c58"
dependencies:
clsx: "npm:^2.0.0"
peerDependencies:
react: ^18.0 || ^19.0
react-dom: ^18.0 || ^19.0
checksum: 10/efc1dcb764fa5f3549d012737e79d423b34e6ad7b0a122849d757f59b45e648afe7c5cfeb1a61464245aa7b393f9251ff91fd49f8f857139d7df185c23d68b68
checksum: 10/18d368ff52151c1e7900819b0d92bb554b3d26135a5d4401a6e3c044fd62ca72a4177b63ba705b93901fa489458a965ce1d1deb2e4f98aa5fb39c276d2b12d38
languageName: node
linkType: hard