diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 4c1fd34f48e..ff937656095 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -708,6 +708,7 @@ export enum TableCellDisplayMode { Image = 'image', JSONView = 'json-view', LcdGauge = 'lcd-gauge', + Pill = 'pill', Sparkline = 'sparkline', } @@ -838,7 +839,38 @@ export enum TableCellHeight { * Table cell options. Each cell has a display mode * and other potential options for that display. */ -export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions); +export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions); + +/** + * Field options for each field within a table (e.g 10, "The String", 64.20, etc.) + * Generally defines alignment, filtering capabilties, display options, etc. + */ +export interface TableFieldOptions { + align: FieldTextAlignment; + cellOptions: TableCellOptions; + /** + * This field is deprecated in favor of using cellOptions + */ + displayMode?: TableCellDisplayMode; + filterable?: boolean; + hidden?: boolean; // ?? default is missing or false ?? + /** + * Hides any header for a column, useful for columns that show some static content or buttons. + */ + hideHeader?: boolean; + inspect: boolean; + minWidth?: number; + width?: number; + /** + * Enables text wrapping for column headers + */ + wrapHeaderText?: boolean; +} + +export const defaultTableFieldOptions: Partial = { + align: 'auto', + inspect: false, +}; /** * Use UTC/GMT timezone @@ -944,37 +976,12 @@ export enum ComparisonOperation { NEQ = 'neq', } -/** - * Field options for each field within a table (e.g 10, "The String", 64.20, etc.) - * Generally defines alignment, filtering capabilties, display options, etc. - */ -export interface TableFieldOptions { - align: FieldTextAlignment; - cellOptions: TableCellOptions; - /** - * This field is deprecated in favor of using cellOptions - */ - displayMode?: TableCellDisplayMode; - filterable?: boolean; - hidden?: boolean; // ?? default is missing or false ?? - /** - * Hides any header for a column, useful for columns that show some static content or buttons. - */ - hideHeader?: boolean; - inspect: boolean; - minWidth?: number; - width?: number; - /** - * Enables text wrapping for column headers - */ - wrapHeaderText?: boolean; +export interface TablePillCellOptions { + color?: string; + colorMode?: ('auto' | 'fixed' | 'mapped'); + type: TableCellDisplayMode.Pill; } -export const defaultTableFieldOptions: Partial = { - align: 'auto', - inspect: false, -}; - /** * A specific timezone from https://en.wikipedia.org/wiki/Tz_database */ diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue index ca61617aedb..0858150ae4c 100644 --- a/packages/grafana-schema/src/common/table.cue +++ b/packages/grafana-schema/src/common/table.cue @@ -4,7 +4,7 @@ package common // in the table such as colored text, JSON, gauge, etc. // The color-background-solid, gradient-gauge, and lcd-gauge // modes are deprecated in favor of new cell subOptions -TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline" | "data-links" | "custom" | "actions" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|DataLinks|Custom|Actions") +TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline" | "data-links" | "custom" | "actions" | "pill" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|DataLinks|Custom|Actions|Pill") // Display mode to the "Colored Background" display // mode for table cells. Either displays a solid color (basic mode) @@ -89,7 +89,7 @@ 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 | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions @cuetsy(kind="type") +TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions @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. @@ -109,3 +109,9 @@ TableFieldOptions: { wrapHeaderText?: bool } @cuetsy(kind="interface") +TablePillCellOptions: { + type: TableCellDisplayMode & "pill" + color?: string + colorMode?: "auto" | "fixed" | "mapped" +} @cuetsy(kind="interface") + diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx new file mode 100644 index 00000000000..a216a9221d1 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx @@ -0,0 +1,219 @@ +import { render, screen } from '@testing-library/react'; + +import { DataFrame, Field, FieldType, GrafanaTheme2, MappingType, createTheme } from '@grafana/data'; +import { TableCellDisplayMode, TablePillCellOptions } from '@grafana/schema'; + +import { mockThemeContext } from '../../../../themes/ThemeContext'; + +import { PillCell, inferPills } from './PillCell'; + +describe('PillCell', () => { + let restoreThemeContext: () => void; + + beforeEach(() => { + restoreThemeContext = mockThemeContext(createTheme()); + }); + + afterEach(() => { + restoreThemeContext(); + }); + + const mockCellOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'auto', + }; + + const mockField: Field = { + name: 'test', + type: FieldType.string, + values: [], + config: {}, + }; + + const mockFrame: DataFrame = { + name: 'test', + fields: [mockField], + length: 1, + }; + + const defaultProps = { + value: 'test-value', + field: mockField, + justifyContent: 'flex-start' as const, + cellOptions: mockCellOptions, + rowIdx: 0, + frame: mockFrame, + height: 30, + width: 100, + theme: {} as GrafanaTheme2, + cellInspect: false, + showFilters: false, + }; + + describe('pill parsing', () => { + it('should render pills for single values', () => { + render(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + }); + + it('should render pills for CSV values', () => { + render(); + expect(screen.getByText('value1')).toBeInTheDocument(); + expect(screen.getByText('value2')).toBeInTheDocument(); + expect(screen.getByText('value3')).toBeInTheDocument(); + }); + + it('should render pills for JSON array values', () => { + render(); + expect(screen.getByText('item1')).toBeInTheDocument(); + expect(screen.getByText('item2')).toBeInTheDocument(); + expect(screen.getByText('item3')).toBeInTheDocument(); + }); + + it('should show dash for empty values', () => { + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + it('should show dash for null values', () => { + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + }); + + describe('color mapping', () => { + // These tests primarily ensure the color logic executes without throwing. + // For true color verification, visual regression tests would be needed. + + it('should use mapped colors when colorMode is mapped', () => { + const mappedOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'mapped', + }; + + render(); + + const successPill = screen.getByText('success'); + const errorPill = screen.getByText('error'); + const warningPill = screen.getByText('warning'); + const unknownPill = screen.getByText('unknown'); + + expect(successPill).toBeInTheDocument(); + expect(errorPill).toBeInTheDocument(); + expect(warningPill).toBeInTheDocument(); + expect(unknownPill).toBeInTheDocument(); + }); + + it('should use field-level value mappings when available', () => { + const mappedOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'mapped', + }; + + // Mock field with value mappings + const fieldWithMappings: Field = { + ...mockField, + config: { + ...mockField.config, + mappings: [ + { + type: MappingType.ValueToText, + options: { + success: { color: '#00FF00' }, + error: { color: '#FF0000' }, + warning: { color: '#FFFF00' }, + }, + }, + ], + }, + display: (value: unknown) => ({ + text: String(value), + color: + String(value) === 'success' + ? '#00FF00' + : String(value) === 'error' + ? '#FF0000' + : String(value) === 'warning' + ? '#FFFF00' + : '#FF780A', + numeric: 0, + }), + }; + + render( + + ); + + const successPill = screen.getByText('success'); + const errorPill = screen.getByText('error'); + const warningPill = screen.getByText('warning'); + const unknownPill = screen.getByText('unknown'); + + expect(successPill).toBeInTheDocument(); + expect(errorPill).toBeInTheDocument(); + expect(warningPill).toBeInTheDocument(); + expect(unknownPill).toBeInTheDocument(); + }); + + it('should use fixed color when colorMode is fixed', () => { + const fixedOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'fixed', + color: '#FF00FF', + }; + + render(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + }); + + it('should use auto color when colorMode is auto', () => { + const autoOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'auto', + }; + + render(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + }); + }); +}); + +describe('inferPills', () => { + // These tests verify the pill parsing logic handles various input formats correctly. + // They ensure the function can extract pill values from different data structures. + + it('should return empty array for null/undefined values', () => { + expect(inferPills(null)).toEqual([]); + expect(inferPills(undefined)).toEqual([]); + expect(inferPills('')).toEqual([]); + }); + + it('should parse single values', () => { + expect(inferPills('test')).toEqual(['test']); + expect(inferPills('"quoted"')).toEqual(['quoted']); + expect(inferPills("'quoted'")).toEqual(['quoted']); + }); + + it('should parse CSV strings', () => { + expect(inferPills('value1,value2,value3')).toEqual(['value1', 'value2', 'value3']); + expect(inferPills(' value1 , value2 , value3 ')).toEqual(['value1', 'value2', 'value3']); + expect(inferPills('value1, ,value3')).toEqual(['value1', 'value3']); + }); + + it('should parse JSON arrays', () => { + expect(inferPills('["item1","item2","item3"]')).toEqual(['item1', 'item2', 'item3']); + expect(inferPills('["item1", "item2", "item3"]')).toEqual(['item1', 'item2', 'item3']); + expect(inferPills('["item1", null, "item3"]')).toEqual(['item1', 'item3']); + }); + + it('should handle mixed content', () => { + // When JSON parsing fails, it falls back to CSV parsing + expect(inferPills('["item1", "item2"],extra')).toEqual(['["item1"', '"item2"]', 'extra']); + expect(inferPills('not-json,value')).toEqual(['not-json', 'value']); + }); +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx new file mode 100644 index 00000000000..aaa103c3dc7 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx @@ -0,0 +1,185 @@ +import { css } from '@emotion/css'; +import { Property } from 'csstype'; +import { useMemo } from 'react'; + +import { GrafanaTheme2, isDataFrame, classicColors, colorManipulator, Field } from '@grafana/data'; +import { TablePillCellOptions } from '@grafana/schema'; + +import { useStyles2 } from '../../../../themes/ThemeContext'; +import { TableCellRendererProps } from '../types'; + +const DEFAULT_PILL_BG_COLOR = '#FF780A'; + +interface Pill { + value: string; + key: string; + bgColor: string; + color: string; +} + +function createPills(pillValues: string[], cellOptions: TableCellRendererProps['cellOptions'], field: Field): Pill[] { + return pillValues.map((pill, index) => { + const bgColor = getPillColor(pill, cellOptions, field); + const textColor = colorManipulator.getContrastRatio('#FFFFFF', bgColor) >= 4.5 ? '#FFFFFF' : '#000000'; + return { + value: pill, + key: `${pill}-${index}`, + bgColor, + color: textColor, + }; + }); +} + +export function PillCell({ value, field, justifyContent, cellOptions }: TableCellRendererProps) { + const styles = useStyles2(getStyles, justifyContent); + + const pills: Pill[] = useMemo(() => { + const pillValues = inferPills(value); + return createPills(pillValues, cellOptions, field); + }, [value, cellOptions, field]); + + if (pills.length === 0) { + return
-
; + } + + return ( +
+
+ {pills.map((pill) => ( + + {pill.value} + + ))} +
+
+ ); +} + +export function inferPills(value: unknown): string[] { + if (!value) { + return []; + } + + // Handle DataFrame - not supported for pills + if (isDataFrame(value)) { + return []; + } + + // Handle different value types + const stringValue = String(value); + + // Try to parse as JSON first + try { + const parsed = JSON.parse(stringValue); + if (Array.isArray(parsed)) { + // JSON array of strings + return parsed + .filter((item) => item != null && item !== '') + .map(String) + .map((text) => text.trim()) + .filter((item) => item !== ''); + } + } catch { + // Not valid JSON, continue with other parsing + } + + // Handle CSV string + if (stringValue.includes(',')) { + return stringValue + .split(',') + .map((text) => text.trim()) + .filter((item) => item !== ''); + } + + // Single value - strip quotes + return [stringValue.replace(/["'`]/g, '').trim()]; +} + +function isPillCellOptions(cellOptions: TableCellRendererProps['cellOptions']): cellOptions is TablePillCellOptions { + return cellOptions?.type === 'pill'; +} + +function getPillColor(pill: string, cellOptions: TableCellRendererProps['cellOptions'], field: Field): string { + if (!isPillCellOptions(cellOptions)) { + return getDeterministicColor(pill); + } + + const colorMode = cellOptions.colorMode || 'auto'; + + // Fixed color mode (highest priority) + if (colorMode === 'fixed' && cellOptions.color) { + return cellOptions.color; + } + + // Mapped color mode - use field's value mappings + if (colorMode === 'mapped') { + // Check if field has value mappings + if (field.config.mappings && field.config.mappings.length > 0) { + // Use the field's display processor to get the mapped value + const displayValue = field.display!(pill); + if (displayValue.color) { + return displayValue.color; + } + } + // Fallback to default color for unmapped values + return cellOptions.color || DEFAULT_PILL_BG_COLOR; + } + + // Auto mode - deterministic color assignment based on string hash + if (colorMode === 'auto') { + return getDeterministicColor(pill); + } + + // Default color for unknown values or fallback + return DEFAULT_PILL_BG_COLOR; +} + +function getDeterministicColor(text: string): string { + // Create a simple hash of the string to get consistent colors + let hash = 0; + for (let i = 0; i < text.length; i++) { + const char = text.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + + // Use absolute value and modulo to get a consistent index + const colorValues = Object.values(classicColors); + const index = Math.abs(hash) % colorValues.length; + + return colorValues[index]; +} + +const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent | undefined) => ({ + cell: css({ + display: 'flex', + justifyContent: justifyContent || 'flex-start', + alignItems: 'center', + height: '100%', + padding: theme.spacing(0.5), + }), + pillsContainer: css({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + maxWidth: '100%', + }), + pill: css({ + display: 'inline-block', + padding: theme.spacing(0.25, 0.75), + borderRadius: theme.shape.radius.default, + fontSize: theme.typography.bodySmall.fontSize, + lineHeight: theme.typography.bodySmall.lineHeight, + fontWeight: theme.typography.fontWeightMedium, + whiteSpace: 'nowrap', + textAlign: 'center', + minWidth: 'fit-content', + }), +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx index 7bb4b39f2cb..92cb1a5e36e 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx @@ -12,6 +12,7 @@ import { DataLinksCell } from './DataLinksCell'; import { GeoCell } from './GeoCell'; import { ImageCell } from './ImageCell'; import { JSONCell } from './JSONCell'; +import { PillCell } from './PillCell'; import { SparklineCell } from './SparklineCell'; export type TableCellRenderer = (props: TableCellRendererProps) => ReactNode; @@ -81,6 +82,8 @@ const DATA_LINKS_RENDERER: TableCellRenderer = (props) => ; +const PILL_RENDERER: TableCellRenderer = (props) => ; + function isCustomCellOptions(options: TableCellOptions): options is TableCustomCellOptions { return options.type === TableCellDisplayMode.Custom; } @@ -104,6 +107,7 @@ const CELL_RENDERERS: Record = { [TableCellDisplayMode.ColorText]: AUTO_RENDERER, [TableCellDisplayMode.ColorBackground]: AUTO_RENDERER, [TableCellDisplayMode.Auto]: AUTO_RENDERER, + [TableCellDisplayMode.Pill]: PILL_RENDERER, }; /** @internal */ diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index f4c9b072b16..16f5ccac9d2 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -196,6 +196,8 @@ export function getCellComponent(displayMode: TableCellDisplayMode, field: Field return DataLinksCell; case TableCellDisplayMode.Actions: return ActionsCell; + case TableCellDisplayMode.Pill: + return DefaultCell; // Legacy table doesn't support pill cells, fallback to default } if (field.type === FieldType.geo) { diff --git a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx index ad5d9e400be..d8752c8e503 100644 --- a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx +++ b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx @@ -10,6 +10,7 @@ import { AutoCellOptionsEditor } from './cells/AutoCellOptionsEditor'; import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor'; import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor'; import { ImageCellOptionsEditor } from './cells/ImageCellOptionsEditor'; +import { PillCellOptionsEditor } from './cells/PillCellOptionsEditor'; import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor'; // The props that any cell type editor are expected @@ -77,6 +78,9 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => { {cellType === TableCellDisplayMode.Image && ( )} + {cellType === TableCellDisplayMode.Pill && ( + + )} ); }; @@ -91,6 +95,7 @@ let cellDisplayModeOptions: Array> = [ { value: { type: TableCellDisplayMode.JSONView }, label: 'JSON View' }, { value: { type: TableCellDisplayMode.Image }, label: 'Image' }, { value: { type: TableCellDisplayMode.Actions }, label: 'Actions' }, + { value: { type: TableCellDisplayMode.Pill }, label: 'Pill' }, ]; const getStyles = (theme: GrafanaTheme2) => ({ diff --git a/public/app/plugins/panel/table/table-new/cells/PillCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/PillCellOptionsEditor.tsx new file mode 100644 index 00000000000..0ff38f33ea9 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/cells/PillCellOptionsEditor.tsx @@ -0,0 +1,66 @@ +import { t } from '@grafana/i18n'; +import { TablePillCellOptions } from '@grafana/schema'; +import { Field, ColorPicker, RadioButtonGroup, Stack } from '@grafana/ui'; + +import { TableCellEditorProps } from '../TableCellOptionEditor'; + +const colorModeOptions: Array<{ value: 'auto' | 'fixed' | 'mapped'; label: string }> = [ + { value: 'auto', label: 'Auto' }, + { value: 'fixed', label: 'Fixed color' }, + { value: 'mapped', label: 'Value mapping' }, +]; + +export const PillCellOptionsEditor = ({ cellOptions, onChange }: TableCellEditorProps) => { + const colorMode = cellOptions.colorMode || 'auto'; + + const onColorModeChange = (mode: 'auto' | 'fixed' | 'mapped') => { + const updatedOptions = { ...cellOptions, colorMode: mode }; + onChange(updatedOptions); + }; + + const onColorChange = (color: string) => { + const updatedOptions = { ...cellOptions, color }; + onChange(updatedOptions); + }; + + return ( + + + + + + {colorMode === 'fixed' && ( + + + + )} + + {colorMode === 'mapped' && ( + +
 
+
+ )} +
+ ); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4f517a706ed..7d978590e2c 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11768,6 +11768,14 @@ "name-show-table-footer": "Show table footer", "name-show-table-header": "Show table header", "name-wrap-header-text": "Wrap header text", + "pill-cell-options-editor": { + "description-color-mode": "Choose how colors are assigned to pills", + "description-fixed-color": "All pills in this column will use this color", + "description-value-mappings-info": "For Value Mappings either use the global table Value Mappings or the Field overrides Value Mappings. The default will fall back to the Color Scheme. ", + "label-color-mode": "Color Mode", + "label-fixed-color": "Fixed Color", + "label-value-mappings-info": "Value Mappings" + }, "placeholder-column-width": "auto", "placeholder-fields": "All Numeric Fields" },