Table: Move cell-specific styles out to their own methods (#108941)

* 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

* Table: Move cell-specific styles out to their own methods

* move styles into their own file

* combine renderer and style declarations to make auto cells work better, complete cleanup of internal cell elements

* 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

* restore bugfix from adversarial AI-generated JSON

* reorganize in light of recent and upcoming changes

* cleanup

* override the whitespace for markdown

* what are these auto imports about...

* fix cell height selector from merge

* also remove cellHeightCustom

* i18n

* avoid the important override in markdown cell styles

* revert some betterer config autoformatting

* slight code cleanup

* s/cat/grot, add color link panel to kitchen sink, fix color link/image link style issues

* update panelid for empty table panel test

* link styles outside of cell style setup

* flesh out kitchen sink examples, update ImageCell and applyToRow

* clean up some inconsistent states

* fix lint issue

* gdev update

* format JSON to satisfy linter

* shortening the text in the long text field

---------

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Paul Marbach
2025-08-07 19:53:52 -04:00
committed by GitHub
co-authored by Leon Sorokin
parent 4b78e54304
commit cc64b17483
25 changed files with 1738 additions and 546 deletions
+19
View File
@@ -157,4 +157,23 @@ module.exports = [
'no-barrel-files/no-barrel-files': 'error',
},
},
{
// custom rule for Table to avoid performance regressions
files: ['packages/grafana-ui/src/components/Table/TableNG/Cells/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['**/themes/ThemeContext'],
importNames: ['useStyles2', 'useTheme2'],
message:
'Do not use "useStyles2" or "useTheme2" in a cell directly. Instead, provide styles to cells via `getDefaultCellStyles` or `getCellSpecificStyles`.',
},
],
},
],
},
},
];
File diff suppressed because it is too large Load Diff
@@ -93,7 +93,7 @@
"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",
"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://grafana.com/media/golden-grot/graphic-golden-grot-awards-hero-image.png'>\"\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"
@@ -1,6 +1,6 @@
import { Page, Locator } from '@playwright/test';
import { test, expect } from '@grafana/plugin-e2e';
import { test, expect, E2ESelectorGroups } from '@grafana/plugin-e2e';
const DASHBOARD_UID = 'dcb9f5e9-8066-4397-889e-864b99555dbb';
@@ -41,6 +41,21 @@ const getColumnIdx = async (loc: Page | Locator, columnName: string) => {
return result;
};
const disableAllTextWrap = async (loc: Page | Locator, selectors: E2ESelectorGroups) => {
// disable text wrapping for all of the columns, since long text with links in them can push the links off the screen.
const wrapTextToggle = loc.locator(
`[aria-label="${selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')}"]`
);
const count = await wrapTextToggle.count();
for (let i = 0; i < count; i++) {
const toggle = wrapTextToggle.nth(i);
if ((await toggle.locator('//preceding-sibling::input').getAttribute('checked')) !== null) {
await toggle.click();
}
}
};
test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] }, () => {
test('Tests word wrap, hover overflow, and cell inspect', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
@@ -214,11 +229,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
// because of text wrapping, we're guaranteed to only be showing a single row when we enable pagination.
await expect(page.getByText(/([\d]+) - ([\d]+) of ([\d]+) rows/)).toBeVisible();
// FIXME horrible selector for the "Wrap text" toggle for the "Long text" column.
await page
.locator('[id="Override 13"]')
.locator(`[aria-label="${selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')}"]`)
.click();
await disableAllTextWrap(page, selectors);
// any number of rows that is not "1" is allowed here, we don't want to police the exact number of rows that
// are rendered since there are tons of factors which could effect this. we do want to grab this number for comparison
@@ -281,13 +292,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink'))
).toBeVisible();
// disable text wrapping for this test to make it easier to click the links, the long lorem ipsum
// can push the links off the screen.
// FIXME very bad selector to get the correct "wrap text" toggle here.
await page
.locator('[id="Override 13"]')
.locator(`[aria-label="${selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')}"]`)
.click();
await disableAllTextWrap(page, selectors);
const infoColumnIdx = await getColumnIdx(page, 'Info');
const pillColIdx = await getColumnIdx(page, 'Pills');
@@ -351,7 +356,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
test('Empty Table panel', async ({ gotoDashboardPage, selectors }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '2' }),
queryParams: new URLSearchParams({ editPanel: '3' }),
});
await expect(
@@ -704,6 +704,7 @@ export enum TableCellDisplayMode {
Custom = 'custom',
DataLinks = 'data-links',
Gauge = 'gauge',
Geo = 'geo',
GradientGauge = 'gradient-gauge',
Image = 'image',
JSONView = 'json-view',
@@ -837,27 +838,6 @@ export interface TablePillCellOptions extends TableWrapTextOptions {
type: TableCellDisplayMode.Pill;
}
export interface TableMarkdownCellOptions {
dynamicHeight?: boolean;
type: TableCellDisplayMode.Markdown;
}
/**
* Height of a table cell
*/
export enum TableCellHeight {
Auto = 'auto',
Lg = 'lg',
Md = 'md',
Sm = 'sm',
}
/**
* 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 | TableMarkdownCellOptions);
/**
* Use UTC/GMT timezone
*/
@@ -973,6 +953,29 @@ export enum ComparisonOperation {
NEQ = 'neq',
}
export interface TableMarkdownCellOptions {
dynamicHeight?: boolean;
type: TableCellDisplayMode.Markdown;
}
/**
* Height of a table cell
*/
export enum TableCellHeight {
Auto = 'auto',
Lg = 'lg',
Md = 'md',
Sm = 'sm',
}
/**
* 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 | TableMarkdownCellOptions | {
type: TableCellDisplayMode.Geo
});
/**
* Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
* Generally defines alignment, filtering capabilties, display options, etc.
+10 -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" | "markdown" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|DataLinks|Custom|Actions|Pill|Markdown")
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" | "geo" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|DataLinks|Custom|Actions|Pill|Markdown|Geo")
// Display mode to the "Colored Background" display
// mode for table cells. Either displays a solid color (basic mode)
@@ -96,12 +96,20 @@ TableMarkdownCellOptions: {
dynamicHeight?: bool
} @cuetsy(kind="interface")
TableMarkdownCellOptions: {
type: TableCellDisplayMode & "markdown"
} @cuetsy(kind="interface")
TableGeoCellOptions: {
type: TableCellDisplayMode & "geo"
}
// 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 | TableMarkdownCellOptions @cuetsy(kind="type")
TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions | TableMarkdownCellOptions | TableGeoCellOptions @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,29 +1,16 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { ActionButton } from '../../../Actions/ActionButton';
import { ActionCellProps } from '../types';
import { ActionCellProps, TableCellStyles } from '../types';
export const ActionsCell = ({ field, rowIdx, getActions }: ActionCellProps) => {
const styles = useStyles2(getStyles);
const actions = getActions(field, rowIdx);
const actions = useMemo(() => getActions(field, rowIdx), [getActions, field, rowIdx]);
if (actions.length === 0) {
return null;
}
return (
<div className={styles.buttonsGap}>
{actions.map((action, i) => (
<ActionButton key={i} action={action} variant="secondary" />
))}
</div>
);
return actions.map((action, i) => <ActionButton key={i} action={action} variant="secondary" />);
};
const getStyles = (theme: GrafanaTheme2) => ({
buttonsGap: css({
display: 'flex',
gap: 6,
}),
});
export const getStyles: TableCellStyles = (theme) => css({ gap: theme.spacing(0.75) });
@@ -1,9 +1,11 @@
import { css } from '@emotion/css';
import { formattedValueToString } from '@grafana/data';
import { MaybeWrapWithLink } from '../MaybeWrapWithLink';
import { AutoCellProps } from '../types';
import { AutoCellProps, TableCellStyles } from '../types';
export default function AutoCell({ value, field, rowIdx }: AutoCellProps) {
export function AutoCell({ value, field, rowIdx }: AutoCellProps) {
const displayValue = field.display!(value);
const formattedValue = formattedValueToString(displayValue);
return (
@@ -12,3 +14,24 @@ export default function AutoCell({ value, field, rowIdx }: AutoCellProps) {
</MaybeWrapWithLink>
);
}
export const getStyles: TableCellStyles = (_theme, { textWrap, shouldOverflow }) =>
css({
...(textWrap && { whiteSpace: 'pre-line' }),
...(shouldOverflow && {
'&:hover, &[aria-selected=true]': {
whiteSpace: 'pre-line',
},
}),
});
export const getJsonCellStyles: TableCellStyles = (_theme, { textWrap, shouldOverflow }) =>
css({
fontFamily: 'monospace',
...(textWrap && { whiteSpace: 'pre' }),
...(shouldOverflow && {
'&:hover, &[aria-selected=true]': {
whiteSpace: 'pre',
},
}),
});
@@ -3,8 +3,9 @@ import { BarGaugeDisplayMode, BarGaugeValueMode, TableCellDisplayMode } from '@g
import { BarGauge } from '../../../BarGauge/BarGauge';
import { MaybeWrapWithLink } from '../MaybeWrapWithLink';
import { TABLE } from '../constants';
import { BarGaugeCellProps } from '../types';
import { extractPixelValue, getCellOptions, getAlignmentFactor } from '../utils';
import { getCellOptions, getAlignmentFactor } from '../utils';
const defaultScale: ThresholdsConfig = {
mode: ThresholdsMode.Absolute,
@@ -23,7 +24,7 @@ const defaultScale: ThresholdsConfig = {
export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: BarGaugeCellProps) => {
const displayValue = field.display!(value);
const cellOptions = getCellOptions(field);
const heightOffset = extractPixelValue(theme.spacing(1));
const heightOffset = TABLE.CELL_PADDING * 2;
let config = getFieldConfigWithMinMax(field, false);
if (!config.thresholds) {
@@ -45,12 +46,14 @@ export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: Bar
}
const alignmentFactors = getAlignmentFactor(field, displayValue, rowIdx!);
// clamp the height of the gauge so it isn't stretched for large rows
const renderedHeight = Math.min(height - heightOffset, TABLE.MAX_CELL_HEIGHT);
return (
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
<BarGauge
width={width}
height={height - heightOffset}
height={renderedHeight}
field={config}
display={field.display}
text={{ valueSize: 14 }}
@@ -1,5 +1,7 @@
import { DataLinksCellProps } from '../types';
import { getCellLinks } from '../utils';
import { css } from '@emotion/css';
import { DataLinksCellProps, TableCellStyles } from '../types';
import { getCellLinks, getJustifyContent } from '../utils';
export const DataLinksCell = ({ field, rowIdx }: DataLinksCellProps) => {
const links = getCellLinks(field, rowIdx);
@@ -14,3 +16,26 @@ export const DataLinksCell = ({ field, rowIdx }: DataLinksCellProps) => {
</a>
));
};
export const getStyles: TableCellStyles = (theme, { textWrap, textAlign }) =>
css({
...(textWrap && {
flexDirection: 'column',
justifyContent: 'center',
alignItems: getJustifyContent(textAlign),
}),
'> a': {
flexWrap: 'nowrap',
...(!textWrap && {
paddingInline: theme.spacing(0.5),
borderRight: `2px solid ${theme.colors.border.medium}`,
'&:first-child': {
paddingInlineStart: 0,
},
'&:last-child': {
paddingInlineEnd: 0,
borderRight: 'none',
},
}),
},
});
@@ -2,13 +2,10 @@ import { css } from '@emotion/css';
import WKT from 'ol/format/WKT';
import { Geometry } from 'ol/geom';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { GeoCellProps } from '../types';
import { GeoCellProps, TableCellStyles } from '../types';
export function GeoCell({ value, justifyContent, height }: GeoCellProps) {
const styles = useStyles2(getStyles);
let disp = '';
export function GeoCell({ value }: GeoCellProps) {
let disp = null;
if (value instanceof Geometry) {
disp = new WKT().writeGeometry(value, {
@@ -19,25 +16,13 @@ export function GeoCell({ value, justifyContent, height }: GeoCellProps) {
disp = `${value}`;
}
return (
<div className={styles.cell} style={{ justifyContent, height }}>
<div className={styles.cellText} style={{ fontFamily: 'monospace' }}>
{disp}
</div>
</div>
);
return disp;
}
const getStyles = () => ({
cell: css({
height: '100%',
display: 'flex',
alignItems: 'center',
padding: '0 8px',
}),
cellText: css({
export const getStyles: TableCellStyles = () =>
css({
fontFamily: 'monospace',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}),
});
});
@@ -1,36 +1,28 @@
import { css } from '@emotion/css';
import { Property } from 'csstype';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { TableCellDisplayMode } from '../../types';
import { MaybeWrapWithLink } from '../MaybeWrapWithLink';
import { ImageCellProps } from '../types';
export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => {
const styles = useStyles2(getStyles, height, justifyContent);
import { ImageCellProps, TableCellStyles } from '../types';
export const ImageCell = ({ cellOptions, field, value, rowIdx }: ImageCellProps) => {
const { text } = field.display!(value);
const { alt, title } =
cellOptions.type === TableCellDisplayMode.Image ? cellOptions : { alt: undefined, title: undefined };
return (
<div className={styles.imageContainer}>
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
<img alt={alt} src={text} className={styles.image} title={title} />
</MaybeWrapWithLink>
</div>
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
<img alt={alt} src={text} title={title} />
</MaybeWrapWithLink>
);
};
const getStyles = (_theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({
image: css({
height,
width: 'auto',
}),
imageContainer: css({
display: 'flex',
justifyContent,
}),
});
export const getStyles: TableCellStyles = () =>
css({
'a, img': {
width: '100%',
height: '100%',
},
img: {
objectFit: 'contain',
},
});
@@ -1,7 +1,9 @@
import { css } from '@emotion/css';
import { renderMarkdown } from '@grafana/data';
import { MaybeWrapWithLink } from '../MaybeWrapWithLink';
import { MarkdownCellProps } from '../types';
import { MarkdownCellProps, TableCellStyles } from '../types';
export function MarkdownCell({ field, rowIdx, disableSanitizeHtml }: MarkdownCellProps) {
return (
@@ -15,3 +17,29 @@ export function MarkdownCell({ field, rowIdx, disableSanitizeHtml }: MarkdownCel
</MaybeWrapWithLink>
);
}
export const getStyles: TableCellStyles = (theme) =>
css({
'&, &:hover, &[aria-selected=true]': {
whiteSpace: 'normal',
},
'.markdown-container': {
width: '100%',
},
'& 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,3 +1,4 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import {
@@ -11,7 +12,7 @@ import {
} from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema';
import { PillCellProps, TableCellValue } from '../types';
import { PillCellProps, TableCellStyles, TableCellValue } from '../types';
export function PillCell({ rowIdx, field, theme }: PillCellProps) {
const value = field.values[rowIdx];
@@ -102,3 +103,25 @@ function getPillColor(value: string, field: Field, theme: GrafanaTheme2): string
return getColorByStringHash(colors, value);
}
export const getStyles: TableCellStyles = (theme, { textWrap, shouldOverflow }) =>
css({
display: 'inline-flex',
gap: theme.spacing(0.5),
flexWrap: textWrap ? 'wrap' : 'nowrap',
...(shouldOverflow && {
'&:hover, &[aria-selected=true]': {
flexWrap: 'wrap',
},
}),
'> span': {
display: 'flex',
padding: theme.spacing(0.25, 0.75),
borderRadius: theme.shape.radius.default,
fontSize: theme.typography.bodySmall.fontSize,
lineHeight: theme.typography.bodySmall.lineHeight,
whiteSpace: 'nowrap',
},
});
@@ -1,5 +1,4 @@
import { css } from '@emotion/css';
import { Property } from 'csstype';
import * as React from 'react';
import {
@@ -10,7 +9,6 @@ import {
isDataFrame,
Field,
isDataFrameWithValue,
GrafanaTheme2,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import {
@@ -24,11 +22,10 @@ import {
VisibilityMode,
} from '@grafana/schema';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { measureText } from '../../../../utils/measureText';
import { FormattedValueDisplay } from '../../../FormattedValueDisplay/FormattedValueDisplay';
import { Sparkline } from '../../../Sparkline/Sparkline';
import { SparklineCellProps } from '../types';
import { SparklineCellProps, TableCellStyles } from '../types';
import { getAlignmentFactor, getCellOptions } from '../utils';
export const defaultSparklineCellConfig: TableSparklineCellOptions = {
@@ -45,8 +42,7 @@ export const defaultSparklineCellConfig: TableSparklineCellOptions = {
};
export const SparklineCell = (props: SparklineCellProps) => {
const { field, value, theme, timeRange, rowIdx, justifyContent, width } = props;
const styles = useStyles2(getStyles, justifyContent);
const { field, value, theme, timeRange, rowIdx, width } = props;
const sparkline = getSparkline(value, field);
if (!sparkline) {
@@ -95,26 +91,14 @@ export const SparklineCell = (props: SparklineCellProps) => {
measureText(`${alignmentFactor.prefix ?? ''}${alignmentFactor.text}${alignmentFactor.suffix ?? ''}`, 16).width +
theme.spacing.gridSize;
valueElement = (
<FormattedValueDisplay
style={{
width: `${valueWidth - theme.spacing.gridSize}px`,
textAlign: 'right',
marginRight: theme.spacing(1),
marginLeft: theme.spacing(1),
}}
className={styles.valueContainer}
value={displayValue}
/>
);
valueElement = <FormattedValueDisplay style={{ width: valueWidth }} value={displayValue} />;
}
// @TODO update width, height
return (
<div className={styles.cellContainer}>
<>
{valueElement}
<Sparkline width={width - valueWidth} height={25} sparkline={sparkline} config={config} theme={theme} />
</div>
</>
);
};
@@ -153,14 +137,10 @@ function getTableSparklineCellOptions(field: Field): TableSparklineCellOptions {
throw new Error(`Expected options type ${TableCellDisplayMode.Sparkline} but got ${options.type}`);
}
const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent | undefined) => ({
cellContainer: css({
display: 'flex',
export const getStyles: TableCellStyles = (theme, { textAlign }) =>
css({
width: '100%',
alignItems: 'center',
justifyContent,
}),
valueContainer: css({
div: { width: 'inherit' },
}),
});
gap: theme.spacing(1),
justifyContent: 'space-between',
...(textAlign === 'right' && { flexDirection: 'row-reverse' }),
});
@@ -89,7 +89,9 @@ describe('TableNG Cells renderers', () => {
cellOptions,
cellInspect: false,
showFilters: false,
justifyContent: 'flex-start',
getActions: jest.fn(() => [
{ title: 'Action', onClick: jest.fn(() => {}), confirmation: jest.fn(), style: {} },
]),
})
);
@@ -110,7 +112,6 @@ describe('TableNG Cells renderers', () => {
cellOptions,
cellInspect: false,
showFilters: false,
justifyContent: 'flex-start',
})
);
}, iterations);
@@ -123,8 +124,8 @@ describe('TableNG Cells renderers', () => {
{ type: TableCellDisplayMode.JSONView, fieldType: FieldType.string },
{ type: TableCellDisplayMode.Image, fieldType: FieldType.string },
{ type: TableCellDisplayMode.DataLinks, fieldType: FieldType.string },
{ type: TableCellDisplayMode.Actions, fieldType: FieldType.string },
{ type: TableCellDisplayMode.ColorText, fieldType: FieldType.string },
{ type: TableCellDisplayMode.Actions, fieldType: FieldType.string },
{ type: TableCellDisplayMode.ColorBackground, fieldType: FieldType.string },
{ type: TableCellDisplayMode.Auto, fieldType: FieldType.string },
] as const)('should render $type cell into the document', ({ type, fieldType }) => {
@@ -1,19 +1,20 @@
import { clsx } from 'clsx';
import { ReactNode } from 'react';
import { Field, FieldType, isDataFrame, isTimeSeriesFrame } from '@grafana/data';
import { Field, FieldType, GrafanaTheme2, isDataFrame, isTimeSeriesFrame } from '@grafana/data';
import { TableCellDisplayMode, TableCellOptions, TableCustomCellOptions } from '../../types';
import { TableCellRendererProps } from '../types';
import { TableCellRendererProps, TableCellStyleOptions, TableCellStyles } from '../types';
import { ActionsCell } from './ActionsCell';
import AutoCell from './AutoCell';
import { ActionsCell, getStyles as getActionsCellStyles } from './ActionsCell';
import { AutoCell, getStyles as getAutoCellStyles, getJsonCellStyles } from './AutoCell';
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';
import { DataLinksCell, getStyles as getDataLinksStyles } from './DataLinksCell';
import { GeoCell, getStyles as getGeoCellStyles } from './GeoCell';
import { ImageCell, getStyles as getImageStyles } from './ImageCell';
import { MarkdownCell, getStyles as getMarkdownCellStyles } from './MarkdownCell';
import { PillCell, getStyles as getPillStyles } from './PillCell';
import { SparklineCell, getStyles as getSparklineCellStyles } from './SparklineCell';
export type TableCellRenderer = (props: TableCellRendererProps) => ReactNode;
@@ -36,7 +37,6 @@ const SPARKLINE_RENDERER: TableCellRenderer = (props) => (
<SparklineCell
value={props.value}
field={props.field}
justifyContent={props.justifyContent}
timeRange={props.timeRange}
rowIdx={props.rowIdx}
theme={props.theme}
@@ -44,19 +44,10 @@ const SPARKLINE_RENDERER: TableCellRenderer = (props) => (
/>
);
const GEO_RENDERER: TableCellRenderer = (props) => (
<GeoCell value={props.value} justifyContent={props.justifyContent} height={props.height} />
);
const GEO_RENDERER: TableCellRenderer = (props) => <GeoCell value={props.value} height={props.height} />;
const IMAGE_RENDERER: TableCellRenderer = (props) => (
<ImageCell
cellOptions={props.cellOptions}
field={props.field}
height={props.height}
justifyContent={props.justifyContent}
value={props.value}
rowIdx={props.rowIdx}
/>
<ImageCell cellOptions={props.cellOptions} field={props.field} value={props.value} rowIdx={props.rowIdx} />
);
const DATA_LINKS_RENDERER: TableCellRenderer = (props) => <DataLinksCell field={props.field} rowIdx={props.rowIdx} />;
@@ -65,12 +56,23 @@ const ACTIONS_RENDERER: TableCellRenderer = ({ field, rowIdx, getActions = () =>
<ActionsCell field={field} rowIdx={rowIdx} getActions={getActions} />
);
const MARKDOWN_RENDERER: TableCellRenderer = (props) => (
<MarkdownCell field={props.field} rowIdx={props.rowIdx} disableSanitizeHtml={props.disableSanitizeHtml} />
);
const PILL_RENDERER: TableCellRenderer = (props) => <PillCell {...props} />;
function isCustomCellOptions(options: TableCellOptions): options is TableCustomCellOptions {
return options.type === TableCellDisplayMode.Custom;
}
function mixinAutoCellStyles(fn: TableCellStyles): TableCellStyles {
return (theme, options) => {
const styles = fn(theme, options);
return clsx(styles, getAutoCellStyles(theme, options));
};
}
const CUSTOM_RENDERER: TableCellRenderer = (props) => {
if (!isCustomCellOptions(props.cellOptions) || !props.cellOptions.cellComponent) {
return null; // nonsensical case, but better to typeguard it than throw.
@@ -79,23 +81,57 @@ 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,
[TableCellDisplayMode.JSONView]: AUTO_RENDERER,
[TableCellDisplayMode.Image]: IMAGE_RENDERER,
[TableCellDisplayMode.DataLinks]: DATA_LINKS_RENDERER,
[TableCellDisplayMode.Actions]: ACTIONS_RENDERER,
[TableCellDisplayMode.Custom]: CUSTOM_RENDERER,
[TableCellDisplayMode.ColorText]: AUTO_RENDERER,
[TableCellDisplayMode.ColorBackground]: AUTO_RENDERER,
[TableCellDisplayMode.Auto]: AUTO_RENDERER,
[TableCellDisplayMode.Markdown]: MARKDOWN_RENDERER,
[TableCellDisplayMode.Pill]: PILL_RENDERER,
const CELL_RENDERERS: Record<TableCellOptions['type'], { renderer: TableCellRenderer; getStyles?: TableCellStyles }> = {
[TableCellDisplayMode.Actions]: {
renderer: ACTIONS_RENDERER,
getStyles: getActionsCellStyles,
},
[TableCellDisplayMode.Auto]: {
renderer: AUTO_RENDERER,
getStyles: getAutoCellStyles,
},
[TableCellDisplayMode.ColorBackground]: {
renderer: AUTO_RENDERER,
getStyles: getAutoCellStyles,
},
[TableCellDisplayMode.ColorText]: {
renderer: AUTO_RENDERER,
getStyles: getAutoCellStyles,
},
[TableCellDisplayMode.Custom]: {
renderer: CUSTOM_RENDERER,
},
[TableCellDisplayMode.DataLinks]: {
renderer: DATA_LINKS_RENDERER,
getStyles: getDataLinksStyles,
},
[TableCellDisplayMode.Gauge]: {
renderer: GAUGE_RENDERER,
},
[TableCellDisplayMode.Geo]: {
renderer: GEO_RENDERER,
getStyles: getGeoCellStyles,
},
[TableCellDisplayMode.Image]: {
renderer: IMAGE_RENDERER,
getStyles: getImageStyles,
},
[TableCellDisplayMode.JSONView]: {
renderer: AUTO_RENDERER,
getStyles: mixinAutoCellStyles(getJsonCellStyles),
},
[TableCellDisplayMode.Pill]: {
renderer: PILL_RENDERER,
getStyles: getPillStyles,
},
[TableCellDisplayMode.Sparkline]: {
renderer: SPARKLINE_RENDERER,
getStyles: getSparklineCellStyles,
},
[TableCellDisplayMode.Markdown]: {
renderer: MARKDOWN_RENDERER,
getStyles: getMarkdownCellStyles,
},
};
// TODO: come up with a more elegant way to handle this.
@@ -108,26 +144,53 @@ const STRING_ONLY_RENDERERS = new Set<TableCellOptions['type']>([
export function getCellRenderer(field: Field, cellOptions: TableCellOptions): TableCellRenderer {
const cellType = cellOptions?.type ?? TableCellDisplayMode.Auto;
if (cellType === TableCellDisplayMode.Auto) {
return getAutoRendererResult(field);
return CELL_RENDERERS[getAutoRendererDisplayMode(field)].renderer;
}
if (STRING_ONLY_RENDERERS.has(cellType) && field.type !== FieldType.string) {
return AUTO_RENDERER;
}
return CELL_RENDERERS[cellType] ?? AUTO_RENDERER;
// cautious fallback to Auto renderer in case some garbage cell type has been provided.
return CELL_RENDERERS[cellType]?.renderer ?? AUTO_RENDERER;
}
/** @internal */
export function getAutoRendererResult(field: Field): TableCellRenderer {
export function getCellSpecificStyles(
cellType: TableCellOptions['type'],
field: Field,
theme: GrafanaTheme2,
options: TableCellStyleOptions
): string | undefined {
if (cellType === TableCellDisplayMode.Auto) {
return getAutoRendererStyles(theme, options, field);
}
return CELL_RENDERERS[cellType]?.getStyles?.(theme, options);
}
/** @internal */
export function getAutoRendererStyles(
theme: GrafanaTheme2,
options: TableCellStyleOptions,
field: Field
): string | undefined {
const impliedDisplayMode = getAutoRendererDisplayMode(field);
if (impliedDisplayMode !== TableCellDisplayMode.Auto) {
return CELL_RENDERERS[impliedDisplayMode]?.getStyles?.(theme, options);
}
return getAutoCellStyles(theme, options);
}
/** @internal */
export function getAutoRendererDisplayMode(field: Field): TableCellOptions['type'] {
if (field.type === FieldType.geo) {
return GEO_RENDERER;
return TableCellDisplayMode.Geo;
}
if (field.type === FieldType.frame) {
const firstValue = field.values[0];
if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) {
return SPARKLINE_RENDERER;
return TableCellDisplayMode.Sparkline;
}
}
return AUTO_RENDERER;
return TableCellDisplayMode.Auto;
}
@@ -1,7 +1,6 @@
import 'react-data-grid/lib/styles.css';
import { css } from '@emotion/css';
import { clsx } from 'clsx';
import { Property } from 'csstype';
import { CSSProperties, Key, ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
Cell,
@@ -22,7 +21,6 @@ import {
Field,
FieldType,
getDisplayProcessor,
GrafanaTheme2,
ReducerID,
} from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
@@ -38,10 +36,10 @@ import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspecto
import { TableCellDisplayMode } from '../types';
import { DataLinksActionsTooltipState } from '../utils';
import { HeaderCell } from './Cells/HeaderCell';
import { RowExpander } from './Cells/RowExpander';
import { TableCellActions } from './Cells/TableCellActions';
import { getCellRenderer } from './Cells/renderers';
import { getCellRenderer, getCellSpecificStyles } from './Cells/renderers';
import { HeaderCell } from './components/HeaderCell';
import { RowExpander } from './components/RowExpander';
import { TableCellActions } from './components/TableCellActions';
import { COLUMN, TABLE } from './constants';
import {
useColumnResize,
@@ -53,7 +51,8 @@ import {
useScrollbarWidth,
useSortedRows,
} from './hooks';
import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types';
import { getDefaultCellStyles, getFooterStyles, getGridStyles, getHeaderCellStyles, getLinkStyles } from './styles';
import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps, TableCellStyleOptions } from './types';
import {
applySort,
computeColWidths,
@@ -74,7 +73,6 @@ import {
isCellInspectEnabled,
shouldTextOverflow,
shouldTextWrap,
TextAlign,
withDataLinksActionsTooltip,
} from './utils';
@@ -364,37 +362,19 @@ export function TableNG(props: TableNGProps) {
: undefined;
const shouldOverflow = rowHeight !== 'auto' && shouldTextOverflow(field);
const shouldWrap = rowHeight === 'auto' || shouldTextWrap(field);
const textWrap = rowHeight === 'auto' || shouldTextWrap(field);
const withTooltip = withDataLinksActionsTooltip(field, cellType);
const canBeColorized =
cellType === TableCellDisplayMode.ColorBackground || cellType === TableCellDisplayMode.ColorText;
const isMonospace = cellType === TableCellDisplayMode.JSONView;
cellType === TableCellDisplayMode.ColorBackground ||
cellType === TableCellDisplayMode.ColorText ||
Boolean(applyToRowBgFn);
const cellStyleOptions: TableCellStyleOptions = { textAlign, textWrap, shouldOverflow };
result.colsWithTooltip[displayName] = withTooltip;
// get static cell class based on col props
let cellClass = '';
switch (cellType) {
case TableCellDisplayMode.Auto:
case TableCellDisplayMode.ColorBackground:
case TableCellDisplayMode.ColorText:
case TableCellDisplayMode.DataLinks:
case TableCellDisplayMode.JSONView:
case TableCellDisplayMode.Pill:
case TableCellDisplayMode.Markdown:
cellClass = getCellStyles(
theme,
cellType,
textAlign,
shouldWrap,
shouldOverflow,
canBeColorized,
isMonospace
);
break;
}
const defaultCellStyles = getDefaultCellStyles(theme, cellStyleOptions);
const cellSpecificStyles = getCellSpecificStyles(cellType, field, theme, cellStyleOptions);
const linkStyles = getLinkStyles(theme, canBeColorized);
// TODO: in future extend this to ensure a non-classic color scheme is set with AutoCell
@@ -433,7 +413,14 @@ export function TableNG(props: TableNGProps) {
};
}
return <Cell key={key} {...props} className={clsx(props.className, cellClass)} style={style} />; // TODO: remove expensive concat
return (
<Cell
key={key}
{...props}
className={clsx(props.className, defaultCellStyles, cellSpecificStyles, linkStyles)}
style={style}
/>
);
};
result.cellRootRenderers[displayName] = renderCellRoot;
@@ -456,7 +443,6 @@ export function TableNG(props: TableNGProps) {
frame,
field,
height,
justifyContent,
rowIdx,
theme,
value,
@@ -812,264 +798,3 @@ const renderRowFactory =
return <Row key={key} {...props} {...handlers} />;
};
const getGridStyles = (
theme: GrafanaTheme2,
{ enablePagination, transparent }: { enablePagination?: boolean; transparent?: boolean }
) => ({
grid: css({
'--rdg-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary,
'--rdg-header-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary,
'--rdg-border-color': theme.colors.border.weak,
'--rdg-color': theme.colors.text.primary,
'--rdg-summary-border-color': theme.colors.border.weak,
'--rdg-summary-border-width': '1px',
// note: this cannot have any transparency since default cells that
// overlay/overflow on hover inherit this background and need to occlude cells below
'--rdg-row-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary,
'--rdg-row-hover-background-color': transparent
? theme.colors.background.primary
: theme.colors.background.secondary,
// TODO: magic 32px number is unfortunate. it would be better to have the content
// flow using flexbox rather than hard-coding this size via a calc
blockSize: enablePagination ? 'calc(100% - 32px)' : '100%',
scrollbarWidth: 'thin',
scrollbarColor: theme.isDark ? '#fff5 #fff1' : '#0005 #0001',
border: 'none',
'.rdg-cell': {
padding: TABLE.CELL_PADDING,
'&:last-child': {
borderInlineEnd: 'none',
},
},
// add a box shadow on hover and selection for all body cells
'& > :not(.rdg-summary-row, .rdg-header-row) > .rdg-cell': {
'&:hover, &[aria-selected=true]': {
boxShadow: theme.shadows.z2,
},
// selected cells should appear below hovered cells.
'&:hover': {
zIndex: theme.zIndex.tooltip - 2,
},
'&[aria-selected=true]': {
zIndex: theme.zIndex.tooltip - 3,
},
},
'.rdg-header-row, .rdg-summary-row': {
'.rdg-cell': {
zIndex: theme.zIndex.tooltip - 1,
},
},
}),
gridNested: css({
height: '100%',
width: `calc(100% - ${COLUMN.EXPANDER_WIDTH - TABLE.CELL_PADDING * 2 - 1}px)`,
overflow: 'visible',
marginLeft: COLUMN.EXPANDER_WIDTH - TABLE.CELL_PADDING - 1,
marginBlock: TABLE.CELL_PADDING,
}),
cellNested: css({
'&[aria-selected=true]': {
outline: 'none',
},
}),
noDataNested: css({
height: TABLE.NESTED_NO_DATA_HEIGHT,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: theme.colors.text.secondary,
fontSize: theme.typography.h4.fontSize,
}),
cellActions: css({
display: 'none',
position: 'absolute',
top: 0,
margin: 'auto',
height: '100%',
color: theme.colors.text.primary,
background: theme.isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',
padding: theme.spacing.x0_5,
paddingInlineStart: theme.spacing.x1,
}),
cellActionsEnd: css({
left: 0,
}),
cellActionsStart: css({
right: 0,
}),
headerRow: css({
paddingBlockStart: 0,
fontWeight: 'normal',
'& .rdg-cell': {
height: '100%',
alignItems: 'flex-end',
},
}),
displayNone: css({
display: 'none',
}),
paginationContainer: css({
alignItems: 'center',
display: 'flex',
justifyContent: 'center',
marginTop: '8px',
width: '100%',
}),
paginationSummary: css({
color: theme.colors.text.secondary,
fontSize: theme.typography.bodySmall.fontSize,
display: 'flex',
justifyContent: 'flex-end',
padding: theme.spacing(0, 1, 0, 2),
}),
menuItem: css({
maxWidth: '200px',
}),
});
const getFooterStyles = (justifyContent: Property.JustifyContent) => ({
footerCellCountRows: css({
display: 'flex',
justifyContent: 'space-between',
}),
footerCell: css({
display: 'flex',
justifyContent: justifyContent || 'space-between',
}),
});
const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) =>
css({
display: 'flex',
gap: theme.spacing(0.5),
zIndex: theme.zIndex.tooltip - 1,
paddingInline: TABLE.CELL_PADDING,
paddingBlockEnd: TABLE.CELL_PADDING,
justifyContent,
'&:last-child': {
borderInlineEnd: 'none',
},
});
const getCellStyles = (
theme: GrafanaTheme2,
cellType: TableCellDisplayMode,
textAlign: TextAlign,
shouldWrap: boolean,
shouldOverflow: boolean,
isColorized: boolean,
isMonospace: boolean
) => {
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),
...(isColorized && { backgroundClip: 'padding-box !important' }),
...(shouldOverflow && { minHeight: '100%' }),
...(shouldWrap && { whiteSpace }),
...(isMonospace && { fontFamily: 'monospace' }),
'&:hover, &[aria-selected=true]': {
'.table-cell-actions': {
display: 'flex',
},
...(shouldOverflow && {
zIndex: theme.zIndex.tooltip - 2,
whiteSpace,
height: 'fit-content',
minWidth: 'fit-content',
...(cellType === TableCellDisplayMode.Pill && {
flexWrap: 'wrap',
}),
}),
},
a: {
cursor: 'pointer',
...(isColorized
? {
color: 'inherit',
textDecoration: 'underline',
}
: {
color: theme.colors.text.link,
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
},
}),
},
...(cellType === TableCellDisplayMode.DataLinks && {
...(shouldWrap && {
flexDirection: 'column',
justifyContent: 'center',
alignItems: getJustifyContent(textAlign),
}),
'> a': {
flexWrap: 'nowrap',
...(!shouldWrap && {
paddingInline: theme.spacing(0.5),
borderRight: `2px solid ${theme.colors.border.medium}`,
'&:first-child': {
paddingInlineStart: 0,
},
'&:last-child': {
paddingInlineEnd: 0,
borderRight: 'none',
},
}),
},
}),
...(cellType === TableCellDisplayMode.Pill && {
display: 'inline-flex',
gap: theme.spacing(0.5),
flexWrap: shouldWrap ? 'wrap' : 'nowrap',
'> span': {
display: 'flex',
padding: theme.spacing(0.25, 0.75),
borderRadius: theme.shape.radius.default,
fontSize: theme.typography.bodySmall.fontSize,
lineHeight: theme.typography.bodySmall.lineHeight,
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,
},
}),
});
};
@@ -89,7 +89,10 @@ const getStyles = (theme: GrafanaTheme2, headerTextWrap?: boolean) => ({
whiteSpace: headerTextWrap ? 'pre-line' : 'nowrap',
'&:hover': {
textDecoration: 'underline',
color: theme.colors.text.link,
},
'&::selection': {
backgroundColor: 'var(--rdg-background-color)',
color: theme.colors.text.secondary,
},
}),
headerCellIcon: css({
@@ -13,7 +13,6 @@ export const TABLE = {
PAGINATION_LIMIT: 750,
SCROLL_BAR_WIDTH: 8,
SCROLL_BAR_MARGIN: 2,
FONT_SIZE: 14,
LINE_HEIGHT: 22,
HEADER_HEIGHT: 28,
NESTED_NO_DATA_HEIGHT: 60,
@@ -0,0 +1,158 @@
import { css } from '@emotion/css';
import { Property } from 'csstype';
import { GrafanaTheme2 } from '@grafana/data';
import { COLUMN, TABLE } from './constants';
import { TableCellStyles } from './types';
import { getJustifyContent } from './utils';
export const getGridStyles = (
theme: GrafanaTheme2,
{ enablePagination, transparent }: { enablePagination?: boolean; transparent?: boolean }
) => ({
grid: css({
'--rdg-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary,
'--rdg-header-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary,
'--rdg-border-color': theme.colors.border.weak,
'--rdg-color': theme.colors.text.primary,
'--rdg-summary-border-color': theme.colors.border.weak,
'--rdg-summary-border-width': '1px',
// note: this cannot have any transparency since default cells that
// overlay/overflow on hover inherit this background and need to occlude cells below
'--rdg-row-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary,
'--rdg-row-hover-background-color': transparent
? theme.colors.background.primary
: theme.colors.background.secondary,
// TODO: magic 32px number is unfortunate. it would be better to have the content
// flow using flexbox rather than hard-coding this size via a calc
blockSize: enablePagination ? 'calc(100% - 32px)' : '100%',
scrollbarWidth: 'thin',
scrollbarColor: theme.isDark ? '#fff5 #fff1' : '#0005 #0001',
border: 'none',
'.rdg-cell': {
padding: TABLE.CELL_PADDING,
'&:last-child': {
borderInlineEnd: 'none',
},
},
// add a box shadow on hover and selection for all body cells
'& > :not(.rdg-summary-row, .rdg-header-row) > .rdg-cell': {
'&:hover, &[aria-selected=true]': { boxShadow: theme.shadows.z2 },
// selected cells should appear below hovered cells.
'&:hover': { zIndex: theme.zIndex.tooltip - 2 },
'&[aria-selected=true]': { zIndex: theme.zIndex.tooltip - 3 },
},
'.rdg-header-row, .rdg-summary-row': {
'.rdg-cell': { zIndex: theme.zIndex.tooltip - 1 },
},
}),
gridNested: css({
height: '100%',
width: `calc(100% - ${COLUMN.EXPANDER_WIDTH - TABLE.CELL_PADDING * 2 - 1}px)`,
overflow: 'visible',
marginLeft: COLUMN.EXPANDER_WIDTH - TABLE.CELL_PADDING - 1,
marginBlock: TABLE.CELL_PADDING,
}),
cellNested: css({ '&[aria-selected=true]': { outline: 'none' } }),
noDataNested: css({
height: TABLE.NESTED_NO_DATA_HEIGHT,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: theme.colors.text.secondary,
fontSize: theme.typography.h4.fontSize,
}),
cellActions: css({
display: 'none',
position: 'absolute',
top: 0,
margin: 'auto',
height: '100%',
color: theme.colors.text.primary,
background: theme.isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',
padding: theme.spacing.x0_5,
paddingInlineStart: theme.spacing.x1,
}),
cellActionsEnd: css({ left: 0 }),
cellActionsStart: css({ right: 0 }),
headerRow: css({
paddingBlockStart: 0,
fontWeight: 'normal',
'& .rdg-cell': { height: '100%', alignItems: 'flex-end' },
}),
displayNone: css({ display: 'none' }),
paginationContainer: css({
alignItems: 'center',
display: 'flex',
justifyContent: 'center',
marginTop: '8px',
width: '100%',
}),
paginationSummary: css({
color: theme.colors.text.secondary,
fontSize: theme.typography.bodySmall.fontSize,
display: 'flex',
justifyContent: 'flex-end',
padding: theme.spacing(0, 1, 0, 2),
}),
menuItem: css({ maxWidth: '200px' }),
});
export const getFooterStyles = (justifyContent: Property.JustifyContent) => ({
footerCellCountRows: css({ display: 'flex', justifyContent: 'space-between' }),
footerCell: css({ display: 'flex', justifyContent: justifyContent || 'space-between' }),
});
export const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) =>
css({
display: 'flex',
gap: theme.spacing(0.5),
zIndex: theme.zIndex.tooltip - 1,
paddingInline: TABLE.CELL_PADDING,
paddingBlockEnd: TABLE.CELL_PADDING,
justifyContent,
'&:last-child': { borderInlineEnd: 'none' },
});
export const getDefaultCellStyles: TableCellStyles = (theme, { textAlign, shouldOverflow }) =>
css({
display: 'flex',
alignItems: 'center',
textAlign,
backgroundClip: 'padding-box !important', // helps when cells have a bg color
justifyContent: getJustifyContent(textAlign),
...(shouldOverflow && { minHeight: '100%' }),
'&:hover, &[aria-selected=true]': {
'.table-cell-actions': { display: 'flex' },
...(shouldOverflow && {
zIndex: theme.zIndex.tooltip - 2,
height: 'fit-content',
minWidth: 'fit-content',
}),
},
});
export const getLinkStyles = (theme: GrafanaTheme2, canBeColorized: boolean) =>
css({
a: {
cursor: 'pointer',
...(canBeColorized
? {
color: 'inherit',
textDecoration: 'underline',
}
: {
color: theme.colors.text.link,
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}),
},
});
@@ -1,4 +1,3 @@
import { Property } from 'csstype';
import { SyntheticEvent } from 'react';
import { Column } from 'react-data-grid';
@@ -19,6 +18,8 @@ import { TableCellHeight, TableFieldOptions } from '@grafana/schema';
import { TableCellInspectorMode } from '../TableCellInspector';
import { TableCellOptions } from '../types';
import { TextAlign } from './utils';
export const FILTER_FOR_OPERATOR = '=';
export const FILTER_OUT_OPERATOR = '!=';
@@ -161,7 +162,6 @@ export interface TableCellRendererProps {
theme: GrafanaTheme2;
cellInspect: boolean;
showFilters: boolean;
justifyContent: Property.JustifyContent;
getActions?: GetActionsFunctionLocal;
disableSanitizeHtml?: boolean;
}
@@ -195,7 +195,6 @@ export interface RowExpanderNGProps {
export interface SparklineCellProps {
field: Field;
justifyContent: Property.JustifyContent;
rowIdx: number;
theme: GrafanaTheme2;
timeRange?: TimeRange;
@@ -215,8 +214,6 @@ export interface BarGaugeCellProps {
export interface ImageCellProps {
cellOptions: TableCellOptions;
field: Field;
height: number;
justifyContent: Property.JustifyContent;
value: TableCellValue;
rowIdx: number;
}
@@ -228,7 +225,6 @@ export interface DataLinksCellProps {
export interface GeoCellProps {
value: TableCellValue;
justifyContent: Property.JustifyContent;
height: number;
}
@@ -262,6 +258,14 @@ export interface PillCellProps {
rowIdx: number;
}
export interface TableCellStyleOptions {
textWrap: boolean;
textAlign: TextAlign;
shouldOverflow: boolean;
}
export type TableCellStyles = (theme: GrafanaTheme2, options: TableCellStyleOptions) => string;
// Comparator for sorting table values
export type Comparator = (a: TableCellValue, b: TableCellValue) => number;