diff --git a/.betterer.results b/.betterer.results
index 0493a87d6d3..b11063eae47 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -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"]
diff --git a/devenv/dev-dashboards/panel-table/table_markdown.json b/devenv/dev-dashboards/panel-table/table_markdown.json
new file mode 100644
index 00000000000..b6aa28ca3b0
--- /dev/null
+++ b/devenv/dev-dashboards/panel-table/table_markdown.json
@@ -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,\"
\n - Definition list
\n - Is something people use sometimes.
\n - Markdown in HTML
\n - Does *not* work **very** well. Use HTML tags.
\n
\"\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,\"red, green, blue\"\n5,\"
\"\n6,\"[Link](https://grafana.com), or HTML link\"\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\nunderline does require an HTML element tho.\"\n10,\"```javascript\nvar s = 'JavaScript syntax highlighting';\nalert(s);\n```\"\n11,\"\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
+}
diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet
index d16f9b96c84..c050297d430 100644
--- a/devenv/jsonnet/dev-dashboards.libsonnet
+++ b/devenv/jsonnet/dev-dashboards.libsonnet
@@ -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'),
diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts
index c7947fcd281..8ac43ceebb1 100644
--- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts
+++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts
@@ -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();
diff --git a/e2e-playwright/panels-suite/table-markdown.spec.ts b/e2e-playwright/panels-suite/table-markdown.spec.ts
new file mode 100644
index 00000000000..b2e127fc92c
--- /dev/null
+++ b/e2e-playwright/panels-suite/table-markdown.spec.ts
@@ -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();
+ });
+ }
+);
diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts
index 9b229c4d1e9..c256e8ed6bd 100644
--- a/packages/grafana-schema/src/common/common.gen.ts
+++ b/packages/grafana-schema/src/common/common.gen.ts
@@ -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
diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue
index 0a15fb926b5..57b7b56dc44 100644
--- a/packages/grafana-schema/src/common/table.cue
+++ b/packages/grafana-schema/src/common/table.cue
@@ -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.
diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index dfb0c9754ea..f04ad6287e1 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -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",
diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx
new file mode 100644
index 00000000000..7d4922c2434
--- /dev/null
+++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx
index 019afb7e243..d1f64824ef3 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx
+++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx
@@ -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) {
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',
}),
});
diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx
index 8d444eddf40..c406d7ffc3f 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx
+++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx
@@ -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 ;
};
+const MARKDOWN_RENDERER: TableCellRenderer = (props) => (
+
+);
+
const CELL_RENDERERS: Record = {
[TableCellDisplayMode.Sparkline]: SPARKLINE_RENDERER,
[TableCellDisplayMode.Gauge]: GAUGE_RENDERER,
@@ -89,9 +94,16 @@ const CELL_RENDERERS: Record = {
[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([
+ 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;
}
diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
index 8d6c9bb5ef8..82ca9192066 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
+++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
@@ -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());
// 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(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 && (
{
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,
+ },
+ }),
});
+};
diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts
index 5c349bdd1ab..47efa394409 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts
+++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts
@@ -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 | ((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;
expandedRows: Set;
typographyCtx: TypographyCtx;
}
@@ -393,7 +399,7 @@ export function useRowHeight({
defaultHeight,
expandedRows,
typographyCtx,
-}: UseRowHeightOptions): number | ((row: TableRow) => number) {
+}: UseRowHeightOptions): NonNullable | ((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;
}
diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts
index d28bab7b668..eaca19734ea 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/types.ts
+++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts
@@ -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;
diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts
index 4dd20cac215..e159c9933cb 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts
+++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts
@@ -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;
diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts
index 96e6c71c018..9ba4a5eddca 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts
+++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts
@@ -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 {
+ 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;
}
/**
diff --git a/public/app/plugins/panel/table/table-new/PaginationEditor.tsx b/public/app/plugins/panel/table/table-new/PaginationEditor.tsx
index 6434c75e1b9..ccd9451ee3f 100644
--- a/public/app/plugins/panel/table/table-new/PaginationEditor.tsx
+++ b/public/app/plugins/panel/table/table-new/PaginationEditor.tsx
@@ -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) {
+export function PaginationEditor({ onChange, value }: StandardEditorProps) {
const changeValue = (event: React.FormEvent | undefined) => {
- if (event?.currentTarget.checked) {
- context.options.footer.show = false;
- }
onChange(event?.currentTarget.checked);
};
diff --git a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx
index 92cbbf0f264..b70723695cb 100644
--- a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx
+++ b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx
@@ -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 && (
)}
+ {cellType === TableCellDisplayMode.Markdown && (
+
+ )}
);
};
diff --git a/public/app/plugins/panel/table/table-new/TablePanel.tsx b/public/app/plugins/panel/table/table-new/TablePanel.tsx
index 5af5472f19b..6859e6368fb 100644
--- a/public/app/plugins/panel/table/table-new/TablePanel.tsx
+++ b/public/app/plugins/panel/table/table-new/TablePanel.tsx
@@ -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 = (
);
diff --git a/public/app/plugins/panel/table/table-new/cells/MarkdownCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/MarkdownCellOptionsEditor.tsx
new file mode 100644
index 00000000000..00c086eb37e
--- /dev/null
+++ b/public/app/plugins/panel/table/table-new/cells/MarkdownCellOptionsEditor.tsx
@@ -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) => {
+ const onDynamicHeightChange = (e: FormEvent) => {
+ cellOptions.dynamicHeight = e.currentTarget.checked;
+ onChange(cellOptions);
+ };
+
+ return (
+
+ Dynamic height{' '}
+
+
+ }
+ >
+
+
+ );
+};
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 927b0c58839..fa1024f8d7a 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -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",
diff --git a/yarn.lock b/yarn.lock
index dae3c03c7d0..69123b8140e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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