Table: Styling from field (#110991)

* Table: Styling from field

* fix mistake with gdev

* e2e for kitchen sink

* add counter-example in e2e for completeness

* unit tests for utils

* update to store style field per-column, replace util

* optimize branches column-level variables
This commit is contained in:
Paul Marbach 2025-09-15 11:34:12 -04:00 committed by GitHub
parent c61624ad3c
commit bb9b890e8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 149 additions and 1 deletions

View File

@ -384,6 +384,10 @@
{
"id": "custom.width",
"value": 139
},
{
"id": "custom.styleField",
"value": "Styling"
}
]
},
@ -461,6 +465,18 @@
"value": ["min", "max"]
}
]
},
{
"matcher": {
"id": "byName",
"options": "Styling"
},
"properties": [
{
"id": "custom.hideFrom.viz",
"value": true
}
]
}
]
},
@ -495,7 +511,7 @@
"scenarioId": "random_walk_table"
},
{
"csvContent": "Info,Image,Image w/ Link,Pills,Data Link,Long Text\ndown,https://grafana.com/media/menus/products/grafana-menu-icon.svg,https://grafana.com/media/menus/products/grafana-menu-icon.svg,hello,https://grafana.com,\"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse tempus et augue et lacinia. Interdum et malesuada fames ac ante ipsum primis in faucibus.\"\nup,https://grafana.com/media/menus/products/grafana-menu-icon-logs.svg,https://grafana.com/media/menus/products/grafana-menu-icon-logs.svg,\"[1,2,3,\"\"foo\"\",\"\"bar\"\"]\",https://grafana.com/solutions/kubernetes/,\"Sed imperdiet eget diam sit amet fringilla. Curabitur quis lacus blandit, mollis diam non, accumsan tortor.\"\nup fast,https://grafana.com/media/menus/products/grafana-menu-icon-traces.svg,https://grafana.com/media/menus/products/grafana-menu-icon-traces.svg,\"foo,1,4,beep\",https://k6.io/,\"Proin ac libero vulputate ex vulputate pharetra ut vel lacus. Phasellus quis dolor sed leo finibus scelerisque. Ut vel finibus leo, sed viverra ipsum.\"\ndown fast,https://grafana.com/media/menus/products/grafana-menu-icon-metrics.svg,https://grafana.com/media/menus/products/grafana-menu-icon-metrics.svg,\"foo,bar,baz,a longer one,bim\",https://grafana.com/products/cloud/,\"Nullam in pulvinar justo. Nunc dictum arcu ac pellentesque bibendum. Sed in erat turpis. Vestibulum eu orci ac ligula lobortis tempus.\"",
"csvContent": "Info,Image,Image w/ Link,Pills,Data Link,Long Text,Styling\ndown,https://grafana.com/media/menus/products/grafana-menu-icon.svg,https://grafana.com/media/menus/products/grafana-menu-icon.svg,hello,https://grafana.com,\"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse tempus et augue et lacinia. Interdum et malesuada fames ac ante ipsum primis in faucibus.\",\nup,https://grafana.com/media/menus/products/grafana-menu-icon-logs.svg,https://grafana.com/media/menus/products/grafana-menu-icon-logs.svg,\"[1,2,3,\"\"foo\"\",\"\"bar\"\"]\",https://grafana.com/solutions/kubernetes/,\"Sed imperdiet eget diam sit amet fringilla. Curabitur quis lacus blandit, mollis diam non, accumsan tortor.\",\"{\"\"textDecoration\"\": \"\"line-through\"\",\"\"background-color\"\":\"\"aquamarine\"\"}\"\nup fast,https://grafana.com/media/menus/products/grafana-menu-icon-traces.svg,https://grafana.com/media/menus/products/grafana-menu-icon-traces.svg,\"foo,1,4,beep\",https://k6.io/,\"Proin ac libero vulputate ex vulputate pharetra ut vel lacus. Phasellus quis dolor sed leo finibus scelerisque. Ut vel finibus leo, sed viverra ipsum.\",\ndown fast,https://grafana.com/media/menus/products/grafana-menu-icon-metrics.svg,https://grafana.com/media/menus/products/grafana-menu-icon-metrics.svg,\"foo,bar,baz,a longer one,bim\",https://grafana.com/products/cloud/,\"Nullam in pulvinar justo. Nunc dictum arcu ac pellentesque bibendum. Sed in erat turpis. Vestibulum eu orci ac ligula lobortis tempus.\",",
"datasource": {
"type": "grafana-testdata-datasource"
},

View File

@ -406,6 +406,50 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
).not.toBeVisible();
});
test('Styling overrides with styling from field', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '1' }),
});
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink'))
).toBeVisible();
await waitForTableLoad(page);
const infoColumnIdx = await getColumnIdx(page, 'Info');
const dataLinkColumnIdx = await getColumnIdx(page, 'Data Link');
const stateColumnHeader = page.getByRole('columnheader').nth(infoColumnIdx);
// filter to only "Up," which we have a style override on.
await stateColumnHeader.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.HeaderButton).click();
const filterContainer = dashboardPage.getByGrafanaSelector(
selectors.components.Panels.Visualization.TableNG.Filters.Container
);
await expect(filterContainer).toBeVisible();
await filterContainer.getByTitle('up', { exact: true }).locator('label').click();
await filterContainer.getByRole('button', { name: 'Ok' }).click();
const cell = await getCell(page, 1, dataLinkColumnIdx);
await expect(cell).toBeVisible();
await expect(cell).toHaveCSS('text-decoration', /line-through/);
// now filter out "up," and confirm that the style override isn't present.
await stateColumnHeader.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.HeaderButton).click();
await expect(filterContainer).toBeVisible();
// select all, then click the first value to unselect it, filtering it out.
await filterContainer.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.SelectAll).click();
await filterContainer.getByTitle('up', { exact: true }).locator('label').click();
await filterContainer.getByRole('button', { name: 'Ok' }).click();
await expect(cell).toBeVisible();
await expect(cell).not.toHaveCSS('text-decoration', /line-through/);
});
test('Empty Table panel', async ({ gotoDashboardPage, selectors }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,

View File

@ -1009,6 +1009,10 @@ export interface TableFieldOptions extends HideableFieldConfig {
hideHeader?: boolean;
inspect: boolean;
minWidth?: number;
/**
* The name of the field which contains styling overrides for this cell
*/
styleField?: string;
/**
* Selecting or hovering this field will show a tooltip containing the content within the target field
*/

View File

@ -129,6 +129,8 @@ TableFieldOptions: {
wrapHeaderText?: bool
// Selecting or hovering this field will show a tooltip containing the content within the target field
tooltip?: TableCellTooltipOptions
// The name of the field which contains styling overrides for this cell
styleField?: string
// options for the footer for this field
footer?: TableFooterOptions
} & HideableFieldConfig @cuetsy(kind="interface")

View File

@ -96,6 +96,7 @@ import {
shouldTextWrap,
withDataLinksActionsTooltip,
getSummaryCellTextAlign,
parseStyleJson,
} from './utils';
const EXPANDED_COLUMN_KEY = 'expanded';
@ -477,6 +478,10 @@ export function TableNG(props: TableNGProps) {
const linkStyles = getLinkStyles(theme, canBeColorized);
const cellParentStyles = clsx(defaultCellStyles, linkStyles);
const maxHeightClassName = maxRowHeight ? getMaxHeightCellStyles(theme, cellStyleOptions) : undefined;
const styleFieldValue = field.config.custom?.styleField;
const styleField = styleFieldValue ? data.fields.find(predicateByName(styleFieldValue)) : undefined;
const styleFieldName = styleField ? getDisplayName(styleField) : undefined;
const hasValidStyleField = Boolean(styleFieldName);
// TODO: in future extend this to ensure a non-classic color scheme is set with AutoCell
@ -504,6 +509,9 @@ export function TableNG(props: TableNGProps) {
const cellColorStyles = getCellColorInlineStyles(cellOptions, displayValue, applyToRowBgFn != null);
Object.assign(style, cellColorStyles);
}
if (hasValidStyleField) {
style = { ...style, ...parseStyleJson(props.row[styleFieldName!]) };
}
return (
<Cell

View File

@ -46,6 +46,7 @@ import {
getDefaultRowHeight,
getDisplayName,
predicateByName,
parseStyleJson,
calculateFooterHeight,
} from './utils';
@ -1459,4 +1460,49 @@ describe('TableNG utils', () => {
expect(predicate(field)).toBe(false);
});
});
describe('parseStyleJson', () => {
it('parses the contents of the styleField for this row and returns a style object', () => {
expect(parseStyleJson('{"color":"red"}')).toEqual({ color: 'red' });
});
it.each([
{ type: 'number', value: 12345 },
{ type: 'boolean', value: true },
{ type: 'null', value: null },
{ type: 'undefined', value: undefined },
{ type: 'object', value: { color: 'red' } },
{ type: 'array', value: ['not', 'a', 'string'] },
])('returns void if input is a $type', ({ value }) => {
expect(parseStyleJson(value)).toBeUndefined();
});
it.each([
{ type: 'array', value: '["not","an","object"]' },
{ type: 'string', value: '"just a string"' },
{ type: 'number', value: '12345' },
{ type: 'boolean', value: 'true' },
{ type: 'null', value: 'null' },
])('returns void and does not throw if the parsed JSON is a $type', ({ value }) => {
expect(parseStyleJson(value)).toBeUndefined();
});
it('returns void and does not throw if this is invalid JSON (but it does console.error)', () => {
jest.spyOn(console, 'error').mockImplementation();
expect(parseStyleJson('{"mal": "formed}')).toBeUndefined();
expect(console.error).toHaveBeenCalled();
});
it('only calls console.error once for a given malformed style', () => {
jest.spyOn(console, 'error').mockImplementation();
for (let i = 0; i < 100; i++) {
parseStyleJson('{"mal": "formed-in-a-new-way}');
}
expect(console.error).toHaveBeenCalledTimes(1);
});
it('returns an object with invalid style properties, because we do not validate the style properties', () => {
expect(parseStyleJson('{"notARealStyle": "someValue"}')).toEqual({ notARealStyle: 'someValue' });
});
});
});

View File

@ -1009,3 +1009,23 @@ export function getSummaryCellTextAlign(textAlign: TextAlign, cellType: TableCel
return textAlign;
}
// we keep this set to avoid spamming the heck out of the console, since it's quite likely that if we fail to parse
// a value once, it'll happen again and again for many rows in a table, and spamming the console is slow.
let warnedAboutStyleJsonSet = new Set<string>();
export function parseStyleJson(rawValue: unknown): CSSProperties | void {
// confirms existence of value and serves as a type guard
if (typeof rawValue === 'string') {
try {
const parsedJsonValue = JSON.parse(rawValue);
if (parsedJsonValue != null && typeof parsedJsonValue === 'object' && !Array.isArray(parsedJsonValue)) {
return parsedJsonValue;
}
} catch (e) {
if (!warnedAboutStyleJsonSet.has(rawValue)) {
console.error(`encountered invalid cell style JSON: ${rawValue}`, e);
warnedAboutStyleJsonSet.add(rawValue);
}
}
}
}

View File

@ -169,6 +169,12 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
],
},
showIf: (cfg) => cfg.tooltip?.field !== undefined,
})
.addFieldNamePicker({
path: 'styleField',
name: t('table.name-styling-from-field', 'Styling from field'),
description: t('table.description-styling-from-field', 'A field containing JSON objects with CSS properties'),
category: cellCategory,
});
},
})

View File

@ -12818,6 +12818,7 @@
"description-column-filter": "Enables/disables field filters in table",
"description-frozen-columns": "Columns are frozen from the left side of the table",
"description-min-column-width": "The minimum width for column auto resizing",
"description-styling-from-field": "A field containing JSON objects with CSS properties",
"description-tooltip-from-field": "Render a cell from a field (hidden or visible) in a tooltip",
"image-cell-options-editor": {
"description-alt-text": "Alternative text that will be displayed if an image can't be displayed or for users who use a screen reader",
@ -12848,6 +12849,7 @@
"name-max-height": "Max row height",
"name-min-column-width": "Minimum column width",
"name-show-table-header": "Show table header",
"name-styling-from-field": "Styling from field",
"name-tooltip-from-field": "Tooltip from field",
"name-tooltip-placement": "Tooltip placement",
"name-wrap-header-text": "Wrap header text",