mirror of https://github.com/grafana/grafana.git
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:
parent
c61624ad3c
commit
bb9b890e8c
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue