Tables: Pills for Table Cells (#107485)

* v2 of pills for tables

* cleanup bettererrrrr

* cleanup pretty

* i18n changes

* add in the option for value mapping

* value mapping

* change to just use the value mapping from the table component

* tests fixed

* betterer all better now

* fix pretty

* i18n

* fix gen issue

* i18n

* fix merge issue

* Refactor pillcell to an interface for said pill, cleanup tests

* mind the space says prettier
This commit is contained in:
Tim Levett
2025-07-08 10:56:39 -05:00
committed by GitHub
parent 0459382b25
commit 0fdcae4e26
9 changed files with 534 additions and 32 deletions
@@ -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<TableFieldOptions> = {
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<TableFieldOptions> = {
align: 'auto',
inspect: false,
};
/**
* A specific timezone from https://en.wikipedia.org/wiki/Tz_database
*/
+8 -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" @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")
@@ -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(<PillCell {...defaultProps} />);
expect(screen.getByText('test-value')).toBeInTheDocument();
});
it('should render pills for CSV values', () => {
render(<PillCell {...defaultProps} value="value1,value2,value3" />);
expect(screen.getByText('value1')).toBeInTheDocument();
expect(screen.getByText('value2')).toBeInTheDocument();
expect(screen.getByText('value3')).toBeInTheDocument();
});
it('should render pills for JSON array values', () => {
render(<PillCell {...defaultProps} value='["item1","item2","item3"]' />);
expect(screen.getByText('item1')).toBeInTheDocument();
expect(screen.getByText('item2')).toBeInTheDocument();
expect(screen.getByText('item3')).toBeInTheDocument();
});
it('should show dash for empty values', () => {
render(<PillCell {...defaultProps} value="" />);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('should show dash for null values', () => {
render(<PillCell {...defaultProps} value={null as unknown as string} />);
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(<PillCell {...defaultProps} value="success,error,warning,unknown" cellOptions={mappedOptions} />);
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(
<PillCell
{...defaultProps}
value="success,error,warning,unknown"
cellOptions={mappedOptions}
field={fieldWithMappings}
/>
);
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(<PillCell {...defaultProps} cellOptions={fixedOptions} />);
expect(screen.getByText('test-value')).toBeInTheDocument();
});
it('should use auto color when colorMode is auto', () => {
const autoOptions: TablePillCellOptions = {
type: TableCellDisplayMode.Pill,
colorMode: 'auto',
};
render(<PillCell {...defaultProps} cellOptions={autoOptions} />);
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']);
});
});
@@ -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 <div className={styles.cell}>-</div>;
}
return (
<div className={styles.cell}>
<div className={styles.pillsContainer}>
{pills.map((pill) => (
<span
key={pill.key}
className={styles.pill}
style={{
backgroundColor: pill.bgColor,
color: pill.color,
}}
>
{pill.value}
</span>
))}
</div>
</div>
);
}
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',
}),
});
@@ -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) => <DataLinksCell field={
const ACTIONS_RENDERER: TableCellRenderer = (props) => <ActionsCell actions={props.actions} />;
const PILL_RENDERER: TableCellRenderer = (props) => <PillCell {...props} />;
function isCustomCellOptions(options: TableCellOptions): options is TableCustomCellOptions {
return options.type === TableCellDisplayMode.Custom;
}
@@ -104,6 +107,7 @@ const CELL_RENDERERS: Record<TableCellOptions['type'], TableCellRenderer> = {
[TableCellDisplayMode.ColorText]: AUTO_RENDERER,
[TableCellDisplayMode.ColorBackground]: AUTO_RENDERER,
[TableCellDisplayMode.Auto]: AUTO_RENDERER,
[TableCellDisplayMode.Pill]: PILL_RENDERER,
};
/** @internal */
@@ -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) {
@@ -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 && (
<ImageCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
{cellType === TableCellDisplayMode.Pill && (
<PillCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
</div>
);
};
@@ -91,6 +95,7 @@ let cellDisplayModeOptions: Array<SelectableValue<TableCellOptions>> = [
{ 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) => ({
@@ -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<TablePillCellOptions>) => {
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 (
<Stack direction="column" gap={1}>
<Field
label={t('table.pill-cell-options-editor.label-color-mode', 'Color Mode')}
description={t(
'table.pill-cell-options-editor.description-color-mode',
'Choose how colors are assigned to pills'
)}
noMargin
>
<RadioButtonGroup value={colorMode} onChange={onColorModeChange} options={colorModeOptions} />
</Field>
{colorMode === 'fixed' && (
<Field
label={t('table.pill-cell-options-editor.label-fixed-color', 'Fixed Color')}
description={t(
'table.pill-cell-options-editor.description-fixed-color',
'All pills in this column will use this color'
)}
noMargin
>
<ColorPicker color={cellOptions.color || '#FF780A'} onChange={onColorChange} enableNamedColors={false} />
</Field>
)}
{colorMode === 'mapped' && (
<Field
label={t('table.pill-cell-options-editor.label-value-mappings-info', 'Value Mappings')}
description={t(
'table.pill-cell-options-editor.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. '
)}
noMargin
>
<div>&nbsp;</div>
</Field>
)}
</Stack>
);
};
+8
View File
@@ -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"
},