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