From bb9b890e8cb2a1d7be7cbaaffd05160314637c9b Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Mon, 15 Sep 2025 11:34:12 -0400 Subject: [PATCH] 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 --- .../panel-table/table_kitchen_sink.json | 18 +++++++- .../panels-suite/table-kitchenSink.spec.ts | 44 ++++++++++++++++++ .../grafana-schema/src/common/common.gen.ts | 4 ++ packages/grafana-schema/src/common/table.cue | 2 + .../src/components/Table/TableNG/TableNG.tsx | 8 ++++ .../components/Table/TableNG/utils.test.ts | 46 +++++++++++++++++++ .../src/components/Table/TableNG/utils.ts | 20 ++++++++ public/app/plugins/panel/table/module.tsx | 6 +++ public/locales/en-US/grafana.json | 2 + 9 files changed, 149 insertions(+), 1 deletion(-) diff --git a/devenv/dev-dashboards/panel-table/table_kitchen_sink.json b/devenv/dev-dashboards/panel-table/table_kitchen_sink.json index 19456d73c83..b0c01e844cd 100644 --- a/devenv/dev-dashboards/panel-table/table_kitchen_sink.json +++ b/devenv/dev-dashboards/panel-table/table_kitchen_sink.json @@ -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" }, diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index 0646506195d..7312027d3b6 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -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, diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 391dea302ef..90434500881 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -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 */ diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue index 4a39845333a..e4ef753b88f 100644 --- a/packages/grafana-schema/src/common/table.cue +++ b/packages/grafana-schema/src/common/table.cue @@ -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") diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 726a9a3ccf0..c45a44eac8d 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -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 ( { 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' }); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 162c8e6df91..a133fe883b0 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -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(); +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); + } + } + } +} diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index a9c386c6e8d..e2ee5b8e97f 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -169,6 +169,12 @@ export const plugin = new PanelPlugin(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, }); }, }) diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e92476e2466..054ade59a75 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",