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:
@@ -708,6 +708,7 @@ export enum TableCellDisplayMode {
|
|||||||
Image = 'image',
|
Image = 'image',
|
||||||
JSONView = 'json-view',
|
JSONView = 'json-view',
|
||||||
LcdGauge = 'lcd-gauge',
|
LcdGauge = 'lcd-gauge',
|
||||||
|
Pill = 'pill',
|
||||||
Sparkline = 'sparkline',
|
Sparkline = 'sparkline',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,7 +839,38 @@ export enum TableCellHeight {
|
|||||||
* Table cell options. Each cell has a display mode
|
* Table cell options. Each cell has a display mode
|
||||||
* and other potential options for that display.
|
* 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
|
* Use UTC/GMT timezone
|
||||||
@@ -944,37 +976,12 @@ export enum ComparisonOperation {
|
|||||||
NEQ = 'neq',
|
NEQ = 'neq',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface TablePillCellOptions {
|
||||||
* Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
|
color?: string;
|
||||||
* Generally defines alignment, filtering capabilties, display options, etc.
|
colorMode?: ('auto' | 'fixed' | 'mapped');
|
||||||
*/
|
type: TableCellDisplayMode.Pill;
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A specific timezone from https://en.wikipedia.org/wiki/Tz_database
|
* A specific timezone from https://en.wikipedia.org/wiki/Tz_database
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package common
|
|||||||
// in the table such as colored text, JSON, gauge, etc.
|
// in the table such as colored text, JSON, gauge, etc.
|
||||||
// The color-background-solid, gradient-gauge, and lcd-gauge
|
// The color-background-solid, gradient-gauge, and lcd-gauge
|
||||||
// modes are deprecated in favor of new cell subOptions
|
// 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
|
// Display mode to the "Colored Background" display
|
||||||
// mode for table cells. Either displays a solid color (basic mode)
|
// 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
|
// Table cell options. Each cell has a display mode
|
||||||
// and other potential options for that display.
|
// 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.)
|
// Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
|
||||||
// Generally defines alignment, filtering capabilties, display options, etc.
|
// Generally defines alignment, filtering capabilties, display options, etc.
|
||||||
@@ -109,3 +109,9 @@ TableFieldOptions: {
|
|||||||
wrapHeaderText?: bool
|
wrapHeaderText?: bool
|
||||||
} @cuetsy(kind="interface")
|
} @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 { GeoCell } from './GeoCell';
|
||||||
import { ImageCell } from './ImageCell';
|
import { ImageCell } from './ImageCell';
|
||||||
import { JSONCell } from './JSONCell';
|
import { JSONCell } from './JSONCell';
|
||||||
|
import { PillCell } from './PillCell';
|
||||||
import { SparklineCell } from './SparklineCell';
|
import { SparklineCell } from './SparklineCell';
|
||||||
|
|
||||||
export type TableCellRenderer = (props: TableCellRendererProps) => ReactNode;
|
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 ACTIONS_RENDERER: TableCellRenderer = (props) => <ActionsCell actions={props.actions} />;
|
||||||
|
|
||||||
|
const PILL_RENDERER: TableCellRenderer = (props) => <PillCell {...props} />;
|
||||||
|
|
||||||
function isCustomCellOptions(options: TableCellOptions): options is TableCustomCellOptions {
|
function isCustomCellOptions(options: TableCellOptions): options is TableCustomCellOptions {
|
||||||
return options.type === TableCellDisplayMode.Custom;
|
return options.type === TableCellDisplayMode.Custom;
|
||||||
}
|
}
|
||||||
@@ -104,6 +107,7 @@ const CELL_RENDERERS: Record<TableCellOptions['type'], TableCellRenderer> = {
|
|||||||
[TableCellDisplayMode.ColorText]: AUTO_RENDERER,
|
[TableCellDisplayMode.ColorText]: AUTO_RENDERER,
|
||||||
[TableCellDisplayMode.ColorBackground]: AUTO_RENDERER,
|
[TableCellDisplayMode.ColorBackground]: AUTO_RENDERER,
|
||||||
[TableCellDisplayMode.Auto]: AUTO_RENDERER,
|
[TableCellDisplayMode.Auto]: AUTO_RENDERER,
|
||||||
|
[TableCellDisplayMode.Pill]: PILL_RENDERER,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ export function getCellComponent(displayMode: TableCellDisplayMode, field: Field
|
|||||||
return DataLinksCell;
|
return DataLinksCell;
|
||||||
case TableCellDisplayMode.Actions:
|
case TableCellDisplayMode.Actions:
|
||||||
return ActionsCell;
|
return ActionsCell;
|
||||||
|
case TableCellDisplayMode.Pill:
|
||||||
|
return DefaultCell; // Legacy table doesn't support pill cells, fallback to default
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === FieldType.geo) {
|
if (field.type === FieldType.geo) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AutoCellOptionsEditor } from './cells/AutoCellOptionsEditor';
|
|||||||
import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor';
|
import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor';
|
||||||
import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor';
|
import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor';
|
||||||
import { ImageCellOptionsEditor } from './cells/ImageCellOptionsEditor';
|
import { ImageCellOptionsEditor } from './cells/ImageCellOptionsEditor';
|
||||||
|
import { PillCellOptionsEditor } from './cells/PillCellOptionsEditor';
|
||||||
import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor';
|
import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor';
|
||||||
|
|
||||||
// The props that any cell type editor are expected
|
// The props that any cell type editor are expected
|
||||||
@@ -77,6 +78,9 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {
|
|||||||
{cellType === TableCellDisplayMode.Image && (
|
{cellType === TableCellDisplayMode.Image && (
|
||||||
<ImageCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
|
<ImageCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
|
||||||
)}
|
)}
|
||||||
|
{cellType === TableCellDisplayMode.Pill && (
|
||||||
|
<PillCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -91,6 +95,7 @@ let cellDisplayModeOptions: Array<SelectableValue<TableCellOptions>> = [
|
|||||||
{ value: { type: TableCellDisplayMode.JSONView }, label: 'JSON View' },
|
{ value: { type: TableCellDisplayMode.JSONView }, label: 'JSON View' },
|
||||||
{ value: { type: TableCellDisplayMode.Image }, label: 'Image' },
|
{ value: { type: TableCellDisplayMode.Image }, label: 'Image' },
|
||||||
{ value: { type: TableCellDisplayMode.Actions }, label: 'Actions' },
|
{ value: { type: TableCellDisplayMode.Actions }, label: 'Actions' },
|
||||||
|
{ value: { type: TableCellDisplayMode.Pill }, label: 'Pill' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
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> </div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11768,6 +11768,14 @@
|
|||||||
"name-show-table-footer": "Show table footer",
|
"name-show-table-footer": "Show table footer",
|
||||||
"name-show-table-header": "Show table header",
|
"name-show-table-header": "Show table header",
|
||||||
"name-wrap-header-text": "Wrap header text",
|
"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-column-width": "auto",
|
||||||
"placeholder-fields": "All Numeric Fields"
|
"placeholder-fields": "All Numeric Fields"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user