mirror of https://github.com/grafana/grafana
TableNG: Restructure panel implementation swapping (#102956)
parent
488581fcc1
commit
7e3efb3df2
@ -1,8 +1 @@ |
||||
import { TableNG } from './TableNG/TableNG'; |
||||
import { Table as TableRT } from './TableRT/Table'; |
||||
import { GeneralTableProps } from './types'; |
||||
|
||||
export function Table(props: GeneralTableProps) { |
||||
let table = props.useTableNg ? <TableNG {...props} /> : <TableRT {...props} />; |
||||
return table; |
||||
} |
||||
export { Table } from './TableRT/Table'; |
||||
|
@ -0,0 +1,15 @@ |
||||
import * as React from 'react'; |
||||
|
||||
import { StandardEditorProps } from '@grafana/data'; |
||||
import { Switch } from '@grafana/ui'; |
||||
|
||||
export function PaginationEditor({ onChange, value, context }: StandardEditorProps<boolean>) { |
||||
const changeValue = (event: React.FormEvent<HTMLInputElement> | undefined) => { |
||||
if (event?.currentTarget.checked) { |
||||
context.options.footer.show = false; |
||||
} |
||||
onChange(event?.currentTarget.checked); |
||||
}; |
||||
|
||||
return <Switch value={Boolean(value)} onChange={changeValue} />; |
||||
} |
@ -0,0 +1,9 @@ |
||||
# Table Panel - Native Plugin |
||||
|
||||
The Table Panel is **included** with Grafana. |
||||
|
||||
The table panel is very flexible, supporting both multiple modes for time series as well as for table, annotation and raw JSON data. It also provides date formatting and value formatting and coloring options. |
||||
|
||||
Check out the [Table Panel Showcase in the Grafana Playground](https://play.grafana.org/d/U_bZIMRMk/7-table-panel-showcase) or read more about it here: |
||||
|
||||
[https://grafana.com/docs/grafana/latest/features/panels/table_panel/](https://grafana.com/docs/grafana/latest/features/panels/table_panel/) |
@ -0,0 +1,100 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { merge } from 'lodash'; |
||||
import { useState } from 'react'; |
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; |
||||
import { TableCellOptions } from '@grafana/schema'; |
||||
import { Field, Select, TableCellDisplayMode, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { AutoCellOptionsEditor } from './cells/AutoCellOptionsEditor'; |
||||
import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor'; |
||||
import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor'; |
||||
import { ImageCellOptionsEditor } from './cells/ImageCellOptionsEditor'; |
||||
import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor'; |
||||
|
||||
// The props that any cell type editor are expected
|
||||
// to handle. In this case the generic type should
|
||||
// be a discriminated interface of TableCellOptions
|
||||
export interface TableCellEditorProps<T> { |
||||
cellOptions: T; |
||||
onChange: (value: T) => void; |
||||
} |
||||
|
||||
interface Props { |
||||
value: TableCellOptions; |
||||
onChange: (v: TableCellOptions) => void; |
||||
} |
||||
|
||||
export const TableCellOptionEditor = ({ value, onChange }: Props) => { |
||||
const cellType = value.type; |
||||
const styles = useStyles2(getStyles); |
||||
const currentMode = cellDisplayModeOptions.find((o) => o.value!.type === cellType)!; |
||||
let [settingCache, setSettingCache] = useState<Record<string, TableCellOptions>>({}); |
||||
|
||||
// Update display mode on change
|
||||
const onCellTypeChange = (v: SelectableValue<TableCellOptions>) => { |
||||
if (v.value !== undefined) { |
||||
// Set the new type of cell starting
|
||||
// with default settings
|
||||
value = v.value; |
||||
|
||||
// When changing cell type see if there were previously stored
|
||||
// settings and merge those with the changed value
|
||||
if (settingCache[value.type] !== undefined && Object.keys(settingCache[value.type]).length > 1) { |
||||
value = merge(value, settingCache[value.type]); |
||||
} |
||||
|
||||
onChange(value); |
||||
} |
||||
}; |
||||
|
||||
// When options for a cell change we merge
|
||||
// any option changes with our options object
|
||||
const onCellOptionsChange = (options: TableCellOptions) => { |
||||
settingCache[value.type] = merge(value, options); |
||||
setSettingCache(settingCache); |
||||
onChange(settingCache[value.type]); |
||||
}; |
||||
|
||||
// Setup and inject editor
|
||||
return ( |
||||
<div className={styles.fixBottomMargin}> |
||||
<Field> |
||||
<Select options={cellDisplayModeOptions} value={currentMode} onChange={onCellTypeChange} /> |
||||
</Field> |
||||
{(cellType === TableCellDisplayMode.Auto || cellType === TableCellDisplayMode.ColorText) && ( |
||||
<AutoCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} /> |
||||
)} |
||||
{cellType === TableCellDisplayMode.Gauge && ( |
||||
<BarGaugeCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} /> |
||||
)} |
||||
{cellType === TableCellDisplayMode.ColorBackground && ( |
||||
<ColorBackgroundCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} /> |
||||
)} |
||||
{cellType === TableCellDisplayMode.Sparkline && ( |
||||
<SparklineCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} /> |
||||
)} |
||||
{cellType === TableCellDisplayMode.Image && ( |
||||
<ImageCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} /> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
let cellDisplayModeOptions: Array<SelectableValue<TableCellOptions>> = [ |
||||
{ value: { type: TableCellDisplayMode.Auto }, label: 'Auto' }, |
||||
{ value: { type: TableCellDisplayMode.Sparkline }, label: 'Sparkline' }, |
||||
{ value: { type: TableCellDisplayMode.ColorText }, label: 'Colored text' }, |
||||
{ value: { type: TableCellDisplayMode.ColorBackground }, label: 'Colored background' }, |
||||
{ value: { type: TableCellDisplayMode.Gauge }, label: 'Gauge' }, |
||||
{ value: { type: TableCellDisplayMode.DataLinks }, label: 'Data links' }, |
||||
{ value: { type: TableCellDisplayMode.JSONView }, label: 'JSON View' }, |
||||
{ value: { type: TableCellDisplayMode.Image }, label: 'Image' }, |
||||
{ value: { type: TableCellDisplayMode.Actions }, label: 'Actions' }, |
||||
]; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
fixBottomMargin: css({ |
||||
marginBottom: theme.spacing(-2), |
||||
}), |
||||
}); |
@ -0,0 +1,189 @@ |
||||
import { css } from '@emotion/css'; |
||||
|
||||
import { |
||||
ActionModel, |
||||
DashboardCursorSync, |
||||
DataFrame, |
||||
FieldMatcherID, |
||||
getFrameDisplayName, |
||||
InterpolateFunction, |
||||
PanelProps, |
||||
SelectableValue, |
||||
Field, |
||||
} from '@grafana/data'; |
||||
import { config, PanelDataErrorView } from '@grafana/runtime'; |
||||
import { Select, usePanelContext, useTheme2 } from '@grafana/ui'; |
||||
import { TableSortByFieldState } from '@grafana/ui/internal'; |
||||
import { TableNG } from '@grafana/ui/unstable'; |
||||
|
||||
import { getActions } from '../../../../features/actions/utils'; |
||||
|
||||
import { hasDeprecatedParentRowIndex, migrateFromParentRowIndexToNestedFrames } from './migrations'; |
||||
import { Options } from './panelcfg.gen'; |
||||
|
||||
interface Props extends PanelProps<Options> {} |
||||
|
||||
export function TablePanel(props: Props) { |
||||
const { data, height, width, options, fieldConfig, id, timeRange, replaceVariables } = props; |
||||
|
||||
const theme = useTheme2(); |
||||
const panelContext = usePanelContext(); |
||||
const frames = hasDeprecatedParentRowIndex(data.series) |
||||
? migrateFromParentRowIndexToNestedFrames(data.series) |
||||
: data.series; |
||||
const count = frames?.length; |
||||
const hasFields = frames.some((frame) => frame.fields.length > 0); |
||||
const currentIndex = getCurrentFrameIndex(frames, options); |
||||
const main = frames[currentIndex]; |
||||
|
||||
let tableHeight = height; |
||||
|
||||
if (!count || !hasFields) { |
||||
return <PanelDataErrorView panelId={id} fieldConfig={fieldConfig} data={data} />; |
||||
} |
||||
|
||||
if (count > 1) { |
||||
const inputHeight = theme.spacing.gridSize * theme.components.height.md; |
||||
const padding = theme.spacing.gridSize; |
||||
|
||||
tableHeight = height - inputHeight - padding; |
||||
} |
||||
|
||||
const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off; |
||||
|
||||
const tableElement = ( |
||||
<TableNG |
||||
height={tableHeight} |
||||
width={width} |
||||
data={main} |
||||
noHeader={!options.showHeader} |
||||
showTypeIcons={options.showTypeIcons} |
||||
resizable={true} |
||||
initialSortBy={options.sortBy} |
||||
onSortByChange={(sortBy) => onSortByChange(sortBy, props)} |
||||
onColumnResize={(displayName, resizedWidth) => onColumnResize(displayName, resizedWidth, props)} |
||||
onCellFilterAdded={panelContext.onAddAdHocFilter} |
||||
footerOptions={options.footer} |
||||
enablePagination={options.footer?.enablePagination} |
||||
cellHeight={options.cellHeight} |
||||
timeRange={timeRange} |
||||
enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair} |
||||
fieldConfig={fieldConfig} |
||||
getActions={getCellActions} |
||||
replaceVariables={replaceVariables} |
||||
/> |
||||
); |
||||
|
||||
if (count === 1) { |
||||
return tableElement; |
||||
} |
||||
|
||||
const names = frames.map((frame, index) => { |
||||
return { |
||||
label: getFrameDisplayName(frame), |
||||
value: index, |
||||
}; |
||||
}); |
||||
|
||||
return ( |
||||
<div className={tableStyles.wrapper}> |
||||
{tableElement} |
||||
<div className={tableStyles.selectWrapper}> |
||||
<Select options={names} value={names[currentIndex]} onChange={(val) => onChangeTableSelection(val, props)} /> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function getCurrentFrameIndex(frames: DataFrame[], options: Options) { |
||||
return options.frameIndex > 0 && options.frameIndex < frames.length ? options.frameIndex : 0; |
||||
} |
||||
|
||||
function onColumnResize(fieldDisplayName: string, width: number, props: Props) { |
||||
const { fieldConfig } = props; |
||||
const { overrides } = fieldConfig; |
||||
|
||||
const matcherId = FieldMatcherID.byName; |
||||
const propId = 'custom.width'; |
||||
|
||||
// look for existing override
|
||||
const override = overrides.find((o) => o.matcher.id === matcherId && o.matcher.options === fieldDisplayName); |
||||
|
||||
if (override) { |
||||
// look for existing property
|
||||
const property = override.properties.find((prop) => prop.id === propId); |
||||
if (property) { |
||||
property.value = width; |
||||
} else { |
||||
override.properties.push({ id: propId, value: width }); |
||||
} |
||||
} else { |
||||
overrides.push({ |
||||
matcher: { id: matcherId, options: fieldDisplayName }, |
||||
properties: [{ id: propId, value: width }], |
||||
}); |
||||
} |
||||
|
||||
props.onFieldConfigChange({ |
||||
...fieldConfig, |
||||
overrides, |
||||
}); |
||||
} |
||||
|
||||
function onSortByChange(sortBy: TableSortByFieldState[], props: Props) { |
||||
props.onOptionsChange({ |
||||
...props.options, |
||||
sortBy, |
||||
}); |
||||
} |
||||
|
||||
function onChangeTableSelection(val: SelectableValue<number>, props: Props) { |
||||
props.onOptionsChange({ |
||||
...props.options, |
||||
frameIndex: val.value || 0, |
||||
}); |
||||
} |
||||
|
||||
// placeholder function; assuming the values are already interpolated
|
||||
const replaceVars: InterpolateFunction = (value: string) => value; |
||||
|
||||
const getCellActions = ( |
||||
dataFrame: DataFrame, |
||||
field: Field, |
||||
rowIndex: number, |
||||
replaceVariables: InterpolateFunction | undefined |
||||
) => { |
||||
const actions: Array<ActionModel<Field>> = []; |
||||
const actionLookup = new Set<string>(); |
||||
|
||||
const actionsModel = getActions( |
||||
dataFrame, |
||||
field, |
||||
field.state!.scopedVars!, |
||||
replaceVariables ?? replaceVars, |
||||
field.config.actions ?? [], |
||||
{ valueRowIndex: rowIndex } |
||||
); |
||||
|
||||
actionsModel.forEach((action) => { |
||||
const key = `${action.title}`; |
||||
if (!actionLookup.has(key)) { |
||||
actions.push(action); |
||||
actionLookup.add(key); |
||||
} |
||||
}); |
||||
|
||||
return actions; |
||||
}; |
||||
|
||||
const tableStyles = { |
||||
wrapper: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
justifyContent: 'space-between', |
||||
height: '100%', |
||||
}), |
||||
selectWrapper: css({ |
||||
padding: '8px 8px 0px 8px', |
||||
}), |
||||
}; |
@ -0,0 +1,82 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`Table Migrations migrates transform out to core transforms 1`] = ` |
||||
{ |
||||
"fieldConfig": { |
||||
"defaults": { |
||||
"custom": {}, |
||||
}, |
||||
"overrides": [], |
||||
}, |
||||
"transformations": [ |
||||
{ |
||||
"id": "seriesToColumns", |
||||
"options": { |
||||
"reducers": [], |
||||
}, |
||||
}, |
||||
], |
||||
} |
||||
`; |
||||
|
||||
exports[`Table Migrations migrates transform out to core transforms 2`] = ` |
||||
{ |
||||
"fieldConfig": { |
||||
"defaults": { |
||||
"custom": {}, |
||||
}, |
||||
"overrides": [], |
||||
}, |
||||
"transformations": [ |
||||
{ |
||||
"id": "seriesToRows", |
||||
"options": { |
||||
"reducers": [], |
||||
}, |
||||
}, |
||||
], |
||||
} |
||||
`; |
||||
|
||||
exports[`Table Migrations migrates transform out to core transforms 3`] = ` |
||||
{ |
||||
"fieldConfig": { |
||||
"defaults": { |
||||
"custom": {}, |
||||
}, |
||||
"overrides": [], |
||||
}, |
||||
"transformations": [ |
||||
{ |
||||
"id": "reduce", |
||||
"options": { |
||||
"includeTimeField": false, |
||||
"reducers": [ |
||||
"mean", |
||||
"max", |
||||
"lastNotNull", |
||||
], |
||||
}, |
||||
}, |
||||
], |
||||
} |
||||
`; |
||||
|
||||
exports[`Table Migrations migrates transform out to core transforms 4`] = ` |
||||
{ |
||||
"fieldConfig": { |
||||
"defaults": { |
||||
"custom": {}, |
||||
}, |
||||
"overrides": [], |
||||
}, |
||||
"transformations": [ |
||||
{ |
||||
"id": "merge", |
||||
"options": { |
||||
"reducers": [], |
||||
}, |
||||
}, |
||||
], |
||||
} |
||||
`; |
@ -0,0 +1,30 @@ |
||||
import { TableAutoCellOptions, TableColorTextCellOptions } from '@grafana/schema'; |
||||
import { Field, Switch, Badge, Label } from '@grafana/ui'; |
||||
|
||||
import { TableCellEditorProps } from '../TableCellOptionEditor'; |
||||
|
||||
export const AutoCellOptionsEditor = ({ |
||||
cellOptions, |
||||
onChange, |
||||
}: TableCellEditorProps<TableAutoCellOptions | TableColorTextCellOptions>) => { |
||||
// Handle row coloring changes
|
||||
const onWrapTextChange = () => { |
||||
cellOptions.wrapText = !cellOptions.wrapText; |
||||
onChange(cellOptions); |
||||
}; |
||||
|
||||
const label = ( |
||||
<Label description="If selected text will be wrapped to the width of text in the configured column"> |
||||
{'Wrap text '} |
||||
<Badge text="Alpha" color="blue" style={{ fontSize: '11px', marginLeft: '5px', lineHeight: '1.2' }} /> |
||||
</Label> |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<Field label={label}> |
||||
<Switch value={cellOptions.wrapText} onChange={onWrapTextChange} /> |
||||
</Field> |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,51 @@ |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { BarGaugeDisplayMode, BarGaugeValueMode, TableBarGaugeCellOptions } from '@grafana/schema'; |
||||
import { Field, RadioButtonGroup, Stack } from '@grafana/ui'; |
||||
|
||||
import { TableCellEditorProps } from '../TableCellOptionEditor'; |
||||
|
||||
type Props = TableCellEditorProps<TableBarGaugeCellOptions>; |
||||
|
||||
export function BarGaugeCellOptionsEditor({ cellOptions, onChange }: Props) { |
||||
// Set the display mode on change
|
||||
const onCellOptionsChange = (v: BarGaugeDisplayMode) => { |
||||
cellOptions.mode = v; |
||||
onChange(cellOptions); |
||||
}; |
||||
|
||||
const onValueModeChange = (v: BarGaugeValueMode) => { |
||||
cellOptions.valueDisplayMode = v; |
||||
onChange(cellOptions); |
||||
}; |
||||
|
||||
return ( |
||||
<Stack direction="column" gap={0}> |
||||
<Field label="Gauge display mode"> |
||||
<RadioButtonGroup |
||||
value={cellOptions?.mode ?? BarGaugeDisplayMode.Gradient} |
||||
onChange={onCellOptionsChange} |
||||
options={barGaugeOpts} |
||||
/> |
||||
</Field> |
||||
<Field label="Value display"> |
||||
<RadioButtonGroup |
||||
value={cellOptions?.valueDisplayMode ?? BarGaugeValueMode.Text} |
||||
onChange={onValueModeChange} |
||||
options={valueModes} |
||||
/> |
||||
</Field> |
||||
</Stack> |
||||
); |
||||
} |
||||
|
||||
const barGaugeOpts: SelectableValue[] = [ |
||||
{ value: BarGaugeDisplayMode.Basic, label: 'Basic' }, |
||||
{ value: BarGaugeDisplayMode.Gradient, label: 'Gradient' }, |
||||
{ value: BarGaugeDisplayMode.Lcd, label: 'Retro LCD' }, |
||||
]; |
||||
|
||||
const valueModes: SelectableValue[] = [ |
||||
{ value: BarGaugeValueMode.Color, label: 'Value color' }, |
||||
{ value: BarGaugeValueMode.Text, label: 'Text color' }, |
||||
{ value: BarGaugeValueMode.Hidden, label: 'Hidden' }, |
||||
]; |
@ -0,0 +1,61 @@ |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { TableCellBackgroundDisplayMode, TableColoredBackgroundCellOptions } from '@grafana/schema'; |
||||
import { Field, RadioButtonGroup, Switch, Label, Badge } from '@grafana/ui'; |
||||
|
||||
import { TableCellEditorProps } from '../TableCellOptionEditor'; |
||||
|
||||
const colorBackgroundOpts: Array<SelectableValue<TableCellBackgroundDisplayMode>> = [ |
||||
{ value: TableCellBackgroundDisplayMode.Basic, label: 'Basic' }, |
||||
{ value: TableCellBackgroundDisplayMode.Gradient, label: 'Gradient' }, |
||||
]; |
||||
|
||||
export const ColorBackgroundCellOptionsEditor = ({ |
||||
cellOptions, |
||||
onChange, |
||||
}: TableCellEditorProps<TableColoredBackgroundCellOptions>) => { |
||||
// Set the display mode on change
|
||||
const onCellOptionsChange = (v: TableCellBackgroundDisplayMode) => { |
||||
cellOptions.mode = v; |
||||
onChange(cellOptions); |
||||
}; |
||||
|
||||
// Handle row coloring changes
|
||||
const onColorRowChange = () => { |
||||
cellOptions.applyToRow = !cellOptions.applyToRow; |
||||
onChange(cellOptions); |
||||
}; |
||||
|
||||
// Handle row coloring changes
|
||||
const onWrapTextChange = () => { |
||||
cellOptions.wrapText = !cellOptions.wrapText; |
||||
onChange(cellOptions); |
||||
}; |
||||
|
||||
const label = ( |
||||
<Label description="If selected text will be wrapped to the width of text in the configured column"> |
||||
{'Wrap text '} |
||||
<Badge text="Alpha" color="blue" style={{ fontSize: '11px', marginLeft: '5px', lineHeight: '1.2' }} /> |
||||
</Label> |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<Field label="Background display mode"> |
||||
<RadioButtonGroup |
||||
value={cellOptions?.mode ?? TableCellBackgroundDisplayMode.Gradient} |
||||
onChange={onCellOptionsChange} |
||||
options={colorBackgroundOpts} |
||||
/> |
||||
</Field> |
||||
<Field |
||||
label="Apply to entire row" |
||||
description="If selected the entire row will be colored as this cell would be." |
||||
> |
||||
<Switch value={cellOptions.applyToRow} onChange={onColorRowChange} /> |
||||
</Field> |
||||
<Field label={label}> |
||||
<Switch value={cellOptions.wrapText} onChange={onWrapTextChange} /> |
||||
</Field> |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,33 @@ |
||||
import { FormEvent } from 'react'; |
||||
|
||||
import { TableImageCellOptions } from '@grafana/schema'; |
||||
import { Field, Input } from '@grafana/ui'; |
||||
|
||||
import { TableCellEditorProps } from '../TableCellOptionEditor'; |
||||
|
||||
export const ImageCellOptionsEditor = ({ cellOptions, onChange }: TableCellEditorProps<TableImageCellOptions>) => { |
||||
const onAltChange = (e: FormEvent<HTMLInputElement>) => { |
||||
cellOptions.alt = e.currentTarget.value; |
||||
onChange(cellOptions); |
||||
}; |
||||
|
||||
const onTitleChange = (e: FormEvent<HTMLInputElement>) => { |
||||
cellOptions.title = e.currentTarget.value; |
||||
onChange(cellOptions); |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
<Field |
||||
label="Alt text" |
||||
description="Alternative text that will be displayed if an image can't be displayed or for users who use a screen reader" |
||||
> |
||||
<Input onChange={onAltChange} defaultValue={cellOptions.alt} /> |
||||
</Field> |
||||
|
||||
<Field label="Title text" description="Text that will be displayed when the image is hovered by a cursor"> |
||||
<Input onChange={onTitleChange} defaultValue={cellOptions.title} /> |
||||
</Field> |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,94 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { useMemo } from 'react'; |
||||
|
||||
import { createFieldConfigRegistry, SetFieldConfigOptionsArgs } from '@grafana/data'; |
||||
import { GraphFieldConfig, TableSparklineCellOptions } from '@grafana/schema'; |
||||
import { VerticalGroup, Field, useStyles2 } from '@grafana/ui'; |
||||
import { defaultSparklineCellConfig } from '@grafana/ui/internal'; |
||||
|
||||
import { getGraphFieldConfig } from '../../../timeseries/config'; |
||||
import { TableCellEditorProps } from '../TableCellOptionEditor'; |
||||
|
||||
type OptionKey = keyof TableSparklineCellOptions; |
||||
|
||||
const optionIds: Array<keyof TableSparklineCellOptions> = [ |
||||
'hideValue', |
||||
'drawStyle', |
||||
'lineInterpolation', |
||||
'barAlignment', |
||||
'lineWidth', |
||||
'fillOpacity', |
||||
'gradientMode', |
||||
'lineStyle', |
||||
'spanNulls', |
||||
'showPoints', |
||||
'pointSize', |
||||
]; |
||||
|
||||
function getChartCellConfig(cfg: GraphFieldConfig): SetFieldConfigOptionsArgs<GraphFieldConfig> { |
||||
const graphFieldConfig = getGraphFieldConfig(cfg); |
||||
return { |
||||
...graphFieldConfig, |
||||
useCustomConfig: (builder) => { |
||||
graphFieldConfig.useCustomConfig?.(builder); |
||||
builder.addBooleanSwitch({ |
||||
path: 'hideValue', |
||||
name: 'Hide value', |
||||
}); |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
export const SparklineCellOptionsEditor = (props: TableCellEditorProps<TableSparklineCellOptions>) => { |
||||
const { cellOptions, onChange } = props; |
||||
|
||||
const registry = useMemo(() => { |
||||
const config = getChartCellConfig(defaultSparklineCellConfig); |
||||
return createFieldConfigRegistry(config, 'ChartCell'); |
||||
}, []); |
||||
|
||||
const style = useStyles2(getStyles); |
||||
|
||||
const values = { ...defaultSparklineCellConfig, ...cellOptions }; |
||||
|
||||
return ( |
||||
<VerticalGroup> |
||||
{registry.list(optionIds.map((id) => `custom.${id}`)).map((item) => { |
||||
if (item.showIf && !item.showIf(values)) { |
||||
return null; |
||||
} |
||||
const Editor = item.editor; |
||||
const path = item.path; |
||||
|
||||
return ( |
||||
<Field label={item.name} key={item.id} className={style.field}> |
||||
<Editor |
||||
onChange={(val) => onChange({ ...cellOptions, [path]: val })} |
||||
value={(isOptionKey(path, values) ? values[path] : undefined) ?? item.defaultValue} |
||||
item={item} |
||||
context={{ data: [] }} |
||||
/> |
||||
</Field> |
||||
); |
||||
})} |
||||
</VerticalGroup> |
||||
); |
||||
}; |
||||
|
||||
// jumping through hoops to avoid using "any"
|
||||
function isOptionKey(key: string, options: TableSparklineCellOptions): key is OptionKey { |
||||
return key in options; |
||||
} |
||||
|
||||
const getStyles = () => ({ |
||||
field: css({ |
||||
width: '100%', |
||||
|
||||
// @TODO don't show "scheme" option for custom gradient mode.
|
||||
// it needs thresholds to work, which are not supported
|
||||
// for area chart cell right now
|
||||
"[title='Use color scheme to define gradient']": { |
||||
display: 'none', |
||||
}, |
||||
}), |
||||
}); |
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1,364 @@ |
||||
import { createDataFrame, FieldType, PanelModel } from '@grafana/data'; |
||||
|
||||
import { migrateFromParentRowIndexToNestedFrames, tablePanelChangedHandler } from './migrations'; |
||||
|
||||
describe('Table Migrations', () => { |
||||
it('migrates transform out to core transforms', () => { |
||||
const toColumns = { |
||||
angular: { |
||||
columns: [], |
||||
styles: [], |
||||
transform: 'timeseries_to_columns', |
||||
options: {}, |
||||
}, |
||||
}; |
||||
const toRows = { |
||||
angular: { |
||||
columns: [], |
||||
styles: [], |
||||
transform: 'timeseries_to_rows', |
||||
options: {}, |
||||
}, |
||||
}; |
||||
const aggregations = { |
||||
angular: { |
||||
columns: [ |
||||
{ |
||||
text: 'Avg', |
||||
value: 'avg', |
||||
$$hashKey: 'object:82', |
||||
}, |
||||
{ |
||||
text: 'Max', |
||||
value: 'max', |
||||
$$hashKey: 'object:83', |
||||
}, |
||||
{ |
||||
text: 'Current', |
||||
value: 'current', |
||||
$$hashKey: 'object:84', |
||||
}, |
||||
], |
||||
styles: [], |
||||
transform: 'timeseries_aggregations', |
||||
options: {}, |
||||
}, |
||||
}; |
||||
const table = { |
||||
angular: { |
||||
columns: [], |
||||
styles: [], |
||||
transform: 'table', |
||||
options: {}, |
||||
}, |
||||
}; |
||||
|
||||
const columnsPanel = {} as PanelModel; |
||||
tablePanelChangedHandler(columnsPanel, 'table-old', toColumns); |
||||
expect(columnsPanel).toMatchSnapshot(); |
||||
const rowsPanel = {} as PanelModel; |
||||
tablePanelChangedHandler(rowsPanel, 'table-old', toRows); |
||||
expect(rowsPanel).toMatchSnapshot(); |
||||
const aggregationsPanel = {} as PanelModel; |
||||
tablePanelChangedHandler(aggregationsPanel, 'table-old', aggregations); |
||||
expect(aggregationsPanel).toMatchSnapshot(); |
||||
const tablePanel = {} as PanelModel; |
||||
tablePanelChangedHandler(tablePanel, 'table-old', table); |
||||
expect(tablePanel).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('migrates styles to field config overrides and defaults', () => { |
||||
const oldStyles = { |
||||
angular: { |
||||
columns: [], |
||||
styles: [ |
||||
{ |
||||
alias: 'Time', |
||||
align: 'auto', |
||||
dateFormat: 'YYYY-MM-DD HH:mm:ss', |
||||
pattern: 'Time', |
||||
type: 'date', |
||||
$$hashKey: 'object:195', |
||||
}, |
||||
{ |
||||
alias: '', |
||||
align: 'left', |
||||
colorMode: 'cell', |
||||
colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'], |
||||
dateFormat: 'YYYY-MM-DD HH:mm:ss', |
||||
decimals: 2, |
||||
mappingType: 1, |
||||
pattern: 'ColorCell', |
||||
thresholds: ['5', '10'], |
||||
type: 'number', |
||||
unit: 'currencyUSD', |
||||
$$hashKey: 'object:196', |
||||
}, |
||||
{ |
||||
alias: '', |
||||
align: 'auto', |
||||
colorMode: 'value', |
||||
colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'], |
||||
dateFormat: 'YYYY-MM-DD HH:mm:ss', |
||||
decimals: 2, |
||||
link: true, |
||||
linkTargetBlank: true, |
||||
linkTooltip: '', |
||||
linkUrl: 'http://www.grafana.com', |
||||
mappingType: 1, |
||||
pattern: 'ColorValue', |
||||
thresholds: ['5', '10'], |
||||
type: 'number', |
||||
unit: 'Bps', |
||||
$$hashKey: 'object:197', |
||||
}, |
||||
{ |
||||
unit: 'short', |
||||
type: 'number', |
||||
alias: '', |
||||
decimals: 2, |
||||
colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'], |
||||
colorMode: null, |
||||
pattern: '/.*/', |
||||
thresholds: [], |
||||
align: 'right', |
||||
}, |
||||
], |
||||
}, |
||||
}; |
||||
|
||||
const panel = {} as PanelModel; |
||||
tablePanelChangedHandler(panel, 'table-old', oldStyles); |
||||
expect(panel).toMatchInlineSnapshot(` |
||||
{ |
||||
"fieldConfig": { |
||||
"defaults": { |
||||
"custom": { |
||||
"align": "right", |
||||
}, |
||||
"decimals": 2, |
||||
"displayName": "", |
||||
"unit": "short", |
||||
}, |
||||
"overrides": [ |
||||
{ |
||||
"matcher": { |
||||
"id": "byName", |
||||
"options": "Time", |
||||
}, |
||||
"properties": [ |
||||
{ |
||||
"id": "displayName", |
||||
"value": "Time", |
||||
}, |
||||
{ |
||||
"id": "unit", |
||||
"value": "time: YYYY-MM-DD HH:mm:ss", |
||||
}, |
||||
{ |
||||
"id": "custom.align", |
||||
"value": null, |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
"matcher": { |
||||
"id": "byName", |
||||
"options": "ColorCell", |
||||
}, |
||||
"properties": [ |
||||
{ |
||||
"id": "unit", |
||||
"value": "currencyUSD", |
||||
}, |
||||
{ |
||||
"id": "decimals", |
||||
"value": 2, |
||||
}, |
||||
{ |
||||
"id": "custom.cellOptions", |
||||
"value": { |
||||
"type": "color-background", |
||||
}, |
||||
}, |
||||
{ |
||||
"id": "custom.align", |
||||
"value": "left", |
||||
}, |
||||
{ |
||||
"id": "thresholds", |
||||
"value": { |
||||
"mode": "absolute", |
||||
"steps": [ |
||||
{ |
||||
"color": "rgba(245, 54, 54, 0.9)", |
||||
"value": -Infinity, |
||||
}, |
||||
{ |
||||
"color": "rgba(237, 129, 40, 0.89)", |
||||
"value": 5, |
||||
}, |
||||
{ |
||||
"color": "rgba(50, 172, 45, 0.97)", |
||||
"value": 10, |
||||
}, |
||||
], |
||||
}, |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
"matcher": { |
||||
"id": "byName", |
||||
"options": "ColorValue", |
||||
}, |
||||
"properties": [ |
||||
{ |
||||
"id": "unit", |
||||
"value": "Bps", |
||||
}, |
||||
{ |
||||
"id": "decimals", |
||||
"value": 2, |
||||
}, |
||||
{ |
||||
"id": "links", |
||||
"value": [ |
||||
{ |
||||
"targetBlank": true, |
||||
"title": "", |
||||
"url": "http://www.grafana.com", |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
"id": "custom.cellOptions", |
||||
"value": { |
||||
"type": "color-text", |
||||
}, |
||||
}, |
||||
{ |
||||
"id": "custom.align", |
||||
"value": null, |
||||
}, |
||||
{ |
||||
"id": "thresholds", |
||||
"value": { |
||||
"mode": "absolute", |
||||
"steps": [ |
||||
{ |
||||
"color": "rgba(245, 54, 54, 0.9)", |
||||
"value": -Infinity, |
||||
}, |
||||
{ |
||||
"color": "rgba(237, 129, 40, 0.89)", |
||||
"value": 5, |
||||
}, |
||||
{ |
||||
"color": "rgba(50, 172, 45, 0.97)", |
||||
"value": 10, |
||||
}, |
||||
], |
||||
}, |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}, |
||||
"transformations": [], |
||||
} |
||||
`);
|
||||
}); |
||||
|
||||
it('migrates hidden fields to override', () => { |
||||
const oldStyles = { |
||||
angular: { |
||||
columns: [], |
||||
styles: [ |
||||
{ |
||||
dateFormat: 'YYYY-MM-DD HH:mm:ss', |
||||
pattern: 'time', |
||||
type: 'hidden', |
||||
}, |
||||
], |
||||
}, |
||||
}; |
||||
|
||||
const panel = {} as PanelModel; |
||||
tablePanelChangedHandler(panel, 'table-old', oldStyles); |
||||
expect(panel.fieldConfig.overrides).toEqual([ |
||||
{ |
||||
matcher: { |
||||
id: 'byName', |
||||
options: 'time', |
||||
}, |
||||
properties: [ |
||||
{ |
||||
id: 'custom.hidden', |
||||
value: true, |
||||
}, |
||||
], |
||||
}, |
||||
]); |
||||
}); |
||||
|
||||
it('migrates DataFrame[] from format using meta.custom.parentRowIndex to format using FieldType.nestedFrames', () => { |
||||
const mainFrame = (refId: string) => { |
||||
return createDataFrame({ |
||||
refId, |
||||
fields: [ |
||||
{ |
||||
name: 'field', |
||||
type: FieldType.string, |
||||
config: {}, |
||||
values: ['a', 'b', 'c'], |
||||
}, |
||||
], |
||||
meta: { |
||||
preferredVisualisationType: 'table', |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
const subFrame = (index: number) => { |
||||
return createDataFrame({ |
||||
refId: 'B', |
||||
fields: [ |
||||
{ |
||||
name: `field_${index}`, |
||||
type: FieldType.string, |
||||
config: {}, |
||||
values: [`${index}_subA`, 'subB', 'subC'], |
||||
}, |
||||
], |
||||
meta: { |
||||
preferredVisualisationType: 'table', |
||||
custom: { |
||||
parentRowIndex: index, |
||||
}, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
const oldFormat = [mainFrame('A'), mainFrame('B'), subFrame(0), subFrame(1)]; |
||||
const newFormat = migrateFromParentRowIndexToNestedFrames(oldFormat); |
||||
expect(newFormat.length).toBe(2); |
||||
expect(newFormat[0].refId).toBe('A'); |
||||
expect(newFormat[1].refId).toBe('B'); |
||||
expect(newFormat[0].fields.length).toBe(1); |
||||
expect(newFormat[1].fields.length).toBe(2); |
||||
expect(newFormat[0].fields[0].name).toBe('field'); |
||||
expect(newFormat[1].fields[0].name).toBe('field'); |
||||
expect(newFormat[1].fields[1].name).toBe('nested'); |
||||
expect(newFormat[1].fields[1].type).toBe(FieldType.nestedFrames); |
||||
expect(newFormat[1].fields[1].values.length).toBe(2); |
||||
expect(newFormat[1].fields[1].values[0][0].refId).toBe('B'); |
||||
expect(newFormat[1].fields[1].values[1][0].refId).toBe('B'); |
||||
expect(newFormat[1].fields[1].values[0][0].length).toBe(3); |
||||
expect(newFormat[1].fields[1].values[0][0].length).toBe(3); |
||||
expect(newFormat[1].fields[1].values[0][0].fields[0].name).toBe('field_0'); |
||||
expect(newFormat[1].fields[1].values[1][0].fields[0].name).toBe('field_1'); |
||||
expect(newFormat[1].fields[1].values[0][0].fields[0].values[0]).toBe('0_subA'); |
||||
expect(newFormat[1].fields[1].values[1][0].fields[0].values[0]).toBe('1_subA'); |
||||
}); |
||||
}); |
@ -0,0 +1,299 @@ |
||||
import { omitBy, isNil, isNumber, defaultTo, groupBy } from 'lodash'; |
||||
|
||||
import { |
||||
PanelModel, |
||||
FieldMatcherID, |
||||
ConfigOverrideRule, |
||||
ThresholdsMode, |
||||
ThresholdsConfig, |
||||
FieldConfig, |
||||
DataFrame, |
||||
FieldType, |
||||
} from '@grafana/data'; |
||||
import { ReduceTransformerOptions } from '@grafana/data/internal'; |
||||
|
||||
import { Options } from './panelcfg.gen'; |
||||
|
||||
/** |
||||
* At 7.0, the `table` panel was swapped from an angular implementation to a react one. |
||||
* The models do not match, so this process will delegate to the old implementation when |
||||
* a saved table configuration exists. |
||||
*/ |
||||
export const tableMigrationHandler = (panel: PanelModel<Options>): Partial<Options> => { |
||||
// Table was saved as an angular table, lets just swap to the 'table-old' panel
|
||||
if (!panel.pluginVersion && 'columns' in panel) { |
||||
console.log('Was angular table', panel); |
||||
} |
||||
|
||||
// Nothing changed
|
||||
return panel.options; |
||||
}; |
||||
|
||||
const transformsMap = { |
||||
timeseries_to_rows: 'seriesToRows', |
||||
timeseries_to_columns: 'seriesToColumns', |
||||
timeseries_aggregations: 'reduce', |
||||
table: 'merge', |
||||
}; |
||||
|
||||
const columnsMap = { |
||||
avg: 'mean', |
||||
min: 'min', |
||||
max: 'max', |
||||
total: 'sum', |
||||
current: 'lastNotNull', |
||||
count: 'count', |
||||
}; |
||||
|
||||
const colorModeMap = { |
||||
cell: 'color-background', |
||||
row: 'color-background', |
||||
value: 'color-text', |
||||
}; |
||||
|
||||
type Transformations = keyof typeof transformsMap; |
||||
|
||||
type Transformation = { |
||||
id: string; |
||||
options: ReduceTransformerOptions; |
||||
}; |
||||
|
||||
type Columns = keyof typeof columnsMap; |
||||
|
||||
type Column = { |
||||
value: Columns; |
||||
text: string; |
||||
}; |
||||
|
||||
type ColorModes = keyof typeof colorModeMap; |
||||
|
||||
const generateThresholds = (thresholds: string[], colors: string[]) => { |
||||
return [-Infinity, ...thresholds].map((threshold, idx) => ({ |
||||
color: colors[idx], |
||||
value: isNumber(threshold) ? threshold : parseInt(threshold, 10), |
||||
})); |
||||
}; |
||||
|
||||
const migrateTransformations = ( |
||||
panel: PanelModel<Partial<Options>>, |
||||
oldOpts: { columns: any; transform: Transformations } |
||||
) => { |
||||
const transformations: Transformation[] = panel.transformations ?? []; |
||||
if (Object.keys(transformsMap).includes(oldOpts.transform)) { |
||||
const opts: ReduceTransformerOptions = { |
||||
reducers: [], |
||||
}; |
||||
if (oldOpts.transform === 'timeseries_aggregations') { |
||||
opts.includeTimeField = false; |
||||
opts.reducers = oldOpts.columns.map((column: Column) => columnsMap[column.value]); |
||||
} |
||||
transformations.push({ |
||||
id: transformsMap[oldOpts.transform], |
||||
options: opts, |
||||
}); |
||||
} |
||||
return transformations; |
||||
}; |
||||
|
||||
type Style = { |
||||
unit: string; |
||||
type: string; |
||||
alias: string; |
||||
decimals: number; |
||||
colors: string[]; |
||||
colorMode: ColorModes; |
||||
pattern: string; |
||||
thresholds: string[]; |
||||
align?: string; |
||||
dateFormat: string; |
||||
link: boolean; |
||||
linkTargetBlank?: boolean; |
||||
linkTooltip?: string; |
||||
linkUrl?: string; |
||||
}; |
||||
|
||||
const migrateTableStyleToOverride = (style: Style) => { |
||||
const fieldMatcherId = /^\/.*\/$/.test(style.pattern) ? FieldMatcherID.byRegexp : FieldMatcherID.byName; |
||||
const override: ConfigOverrideRule = { |
||||
matcher: { |
||||
id: fieldMatcherId, |
||||
options: style.pattern, |
||||
}, |
||||
properties: [], |
||||
}; |
||||
|
||||
if (style.alias) { |
||||
override.properties.push({ |
||||
id: 'displayName', |
||||
value: style.alias, |
||||
}); |
||||
} |
||||
|
||||
if (style.unit) { |
||||
override.properties.push({ |
||||
id: 'unit', |
||||
value: style.unit, |
||||
}); |
||||
} |
||||
|
||||
if (style.decimals) { |
||||
override.properties.push({ |
||||
id: 'decimals', |
||||
value: style.decimals, |
||||
}); |
||||
} |
||||
|
||||
if (style.type === 'date') { |
||||
override.properties.push({ |
||||
id: 'unit', |
||||
value: `time: ${style.dateFormat}`, |
||||
}); |
||||
} |
||||
|
||||
if (style.type === 'hidden') { |
||||
override.properties.push({ |
||||
id: 'custom.hidden', |
||||
value: true, |
||||
}); |
||||
} |
||||
|
||||
if (style.link) { |
||||
override.properties.push({ |
||||
id: 'links', |
||||
value: [ |
||||
{ |
||||
title: defaultTo(style.linkTooltip, ''), |
||||
url: defaultTo(style.linkUrl, ''), |
||||
targetBlank: defaultTo(style.linkTargetBlank, false), |
||||
}, |
||||
], |
||||
}); |
||||
} |
||||
|
||||
if (style.colorMode) { |
||||
override.properties.push({ |
||||
id: 'custom.cellOptions', |
||||
value: { |
||||
type: colorModeMap[style.colorMode], |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
if (style.align) { |
||||
override.properties.push({ |
||||
id: 'custom.align', |
||||
value: style.align === 'auto' ? null : style.align, |
||||
}); |
||||
} |
||||
|
||||
if (style.thresholds?.length) { |
||||
override.properties.push({ |
||||
id: 'thresholds', |
||||
value: { |
||||
mode: ThresholdsMode.Absolute, |
||||
steps: generateThresholds(style.thresholds, style.colors), |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
return override; |
||||
}; |
||||
|
||||
const migrateDefaults = (prevDefaults: Style) => { |
||||
let defaults: FieldConfig = { |
||||
custom: {}, |
||||
}; |
||||
if (prevDefaults) { |
||||
defaults = omitBy( |
||||
{ |
||||
unit: prevDefaults.unit, |
||||
decimals: prevDefaults.decimals, |
||||
displayName: prevDefaults.alias, |
||||
custom: { |
||||
align: prevDefaults.align === 'auto' ? null : prevDefaults.align, |
||||
}, |
||||
}, |
||||
isNil |
||||
); |
||||
|
||||
if (prevDefaults.thresholds.length) { |
||||
const thresholds: ThresholdsConfig = { |
||||
mode: ThresholdsMode.Absolute, |
||||
steps: generateThresholds(prevDefaults.thresholds, prevDefaults.colors), |
||||
}; |
||||
defaults.thresholds = thresholds; |
||||
} |
||||
|
||||
if (prevDefaults.colorMode) { |
||||
defaults.custom.cellOptions = { |
||||
type: colorModeMap[prevDefaults.colorMode], |
||||
}; |
||||
} |
||||
} |
||||
return defaults; |
||||
}; |
||||
|
||||
/** |
||||
* This is called when the panel changes from another panel |
||||
*/ |
||||
export const tablePanelChangedHandler = ( |
||||
panel: PanelModel<Partial<Options>>, |
||||
prevPluginId: string, |
||||
prevOptions: any |
||||
) => { |
||||
// Changing from angular table panel
|
||||
if (prevPluginId === 'table-old' && prevOptions.angular) { |
||||
const oldOpts = prevOptions.angular; |
||||
const transformations = migrateTransformations(panel, oldOpts); |
||||
const prevDefaults = oldOpts.styles.find((style: any) => style.pattern === '/.*/'); |
||||
const defaults = migrateDefaults(prevDefaults); |
||||
const overrides = oldOpts.styles.filter((style: any) => style.pattern !== '/.*/').map(migrateTableStyleToOverride); |
||||
|
||||
panel.transformations = transformations; |
||||
panel.fieldConfig = { |
||||
defaults, |
||||
overrides, |
||||
}; |
||||
} |
||||
|
||||
return {}; |
||||
}; |
||||
|
||||
const getMainFrames = (frames: DataFrame[] | null) => { |
||||
return frames?.filter((df) => df.meta?.custom?.parentRowIndex === undefined) || [frames?.[0]]; |
||||
}; |
||||
|
||||
/** |
||||
* In 9.3 meta.custom.parentRowIndex was introduced to support sub-tables. |
||||
* In 10.2 meta.custom.parentRowIndex was deprecated in favor of FieldType.nestedFrames, which supports multiple nested frames. |
||||
* Migrate DataFrame[] from using meta.custom.parentRowIndex to using FieldType.nestedFrames |
||||
*/ |
||||
export const migrateFromParentRowIndexToNestedFrames = (frames: DataFrame[] | null) => { |
||||
const migratedFrames: DataFrame[] = []; |
||||
const mainFrames = getMainFrames(frames).filter( |
||||
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0 |
||||
); |
||||
|
||||
mainFrames?.forEach((frame) => { |
||||
const subFrames = frames?.filter((df) => frame.refId === df.refId && df.meta?.custom?.parentRowIndex !== undefined); |
||||
const subFramesGrouped = groupBy(subFrames, (frame: DataFrame) => frame.meta?.custom?.parentRowIndex); |
||||
const subFramesByIndex = Object.keys(subFramesGrouped).map((key) => subFramesGrouped[key]); |
||||
const migratedFrame = { ...frame }; |
||||
|
||||
if (subFrames && subFrames.length > 0) { |
||||
migratedFrame.fields.push({ |
||||
name: 'nested', |
||||
type: FieldType.nestedFrames, |
||||
config: {}, |
||||
values: subFramesByIndex, |
||||
}); |
||||
} |
||||
migratedFrames.push(migratedFrame); |
||||
}); |
||||
|
||||
return migratedFrames; |
||||
}; |
||||
|
||||
export const hasDeprecatedParentRowIndex = (frames: DataFrame[] | null) => { |
||||
return frames?.some((df) => df.meta?.custom?.parentRowIndex !== undefined); |
||||
}; |
@ -0,0 +1,187 @@ |
||||
import { |
||||
FieldOverrideContext, |
||||
FieldType, |
||||
getFieldDisplayName, |
||||
PanelPlugin, |
||||
ReducerID, |
||||
standardEditorsRegistry, |
||||
identityOverrideProcessor, |
||||
FieldConfigProperty, |
||||
} from '@grafana/data'; |
||||
import { TableCellOptions, TableCellDisplayMode, defaultTableFieldOptions, TableCellHeight } from '@grafana/schema'; |
||||
|
||||
import { PaginationEditor } from './PaginationEditor'; |
||||
import { TableCellOptionEditor } from './TableCellOptionEditor'; |
||||
import { TablePanel } from './TablePanel'; |
||||
import { tableMigrationHandler, tablePanelChangedHandler } from './migrations'; |
||||
import { Options, defaultOptions, FieldConfig } from './panelcfg.gen'; |
||||
import { TableSuggestionsSupplier } from './suggestions'; |
||||
|
||||
const footerCategory = 'Table footer'; |
||||
const cellCategory = ['Cell options']; |
||||
|
||||
export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel) |
||||
.setPanelChangeHandler(tablePanelChangedHandler) |
||||
.setMigrationHandler(tableMigrationHandler) |
||||
.useFieldConfig({ |
||||
standardOptions: { |
||||
[FieldConfigProperty.Actions]: { |
||||
hideFromDefaults: false, |
||||
}, |
||||
}, |
||||
useCustomConfig: (builder) => { |
||||
builder |
||||
.addNumberInput({ |
||||
path: 'minWidth', |
||||
name: 'Minimum column width', |
||||
description: 'The minimum width for column auto resizing', |
||||
settings: { |
||||
placeholder: '150', |
||||
min: 50, |
||||
max: 500, |
||||
}, |
||||
shouldApply: () => true, |
||||
defaultValue: defaultTableFieldOptions.minWidth, |
||||
}) |
||||
.addNumberInput({ |
||||
path: 'width', |
||||
name: 'Column width', |
||||
settings: { |
||||
placeholder: 'auto', |
||||
min: 20, |
||||
max: 300, |
||||
}, |
||||
shouldApply: () => true, |
||||
defaultValue: defaultTableFieldOptions.width, |
||||
}) |
||||
.addRadio({ |
||||
path: 'align', |
||||
name: 'Column alignment', |
||||
settings: { |
||||
options: [ |
||||
{ label: 'Auto', value: 'auto' }, |
||||
{ label: 'Left', value: 'left' }, |
||||
{ label: 'Center', value: 'center' }, |
||||
{ label: 'Right', value: 'right' }, |
||||
], |
||||
}, |
||||
defaultValue: defaultTableFieldOptions.align, |
||||
}) |
||||
.addCustomEditor<void, TableCellOptions>({ |
||||
id: 'cellOptions', |
||||
path: 'cellOptions', |
||||
name: 'Cell type', |
||||
editor: TableCellOptionEditor, |
||||
override: TableCellOptionEditor, |
||||
defaultValue: defaultTableFieldOptions.cellOptions, |
||||
process: identityOverrideProcessor, |
||||
category: cellCategory, |
||||
shouldApply: () => true, |
||||
}) |
||||
.addBooleanSwitch({ |
||||
path: 'inspect', |
||||
name: 'Cell value inspect', |
||||
description: 'Enable cell value inspection in a modal window', |
||||
defaultValue: false, |
||||
category: cellCategory, |
||||
showIf: (cfg) => { |
||||
return ( |
||||
cfg.cellOptions.type === TableCellDisplayMode.Auto || |
||||
cfg.cellOptions.type === TableCellDisplayMode.JSONView || |
||||
cfg.cellOptions.type === TableCellDisplayMode.ColorText || |
||||
cfg.cellOptions.type === TableCellDisplayMode.ColorBackground |
||||
); |
||||
}, |
||||
}) |
||||
.addBooleanSwitch({ |
||||
path: 'filterable', |
||||
name: 'Column filter', |
||||
description: 'Enables/disables field filters in table', |
||||
defaultValue: defaultTableFieldOptions.filterable, |
||||
}) |
||||
.addBooleanSwitch({ |
||||
path: 'hidden', |
||||
name: 'Hide in table', |
||||
defaultValue: undefined, |
||||
hideFromDefaults: true, |
||||
}); |
||||
}, |
||||
}) |
||||
.setPanelOptions((builder) => { |
||||
builder |
||||
.addBooleanSwitch({ |
||||
path: 'showHeader', |
||||
name: 'Show table header', |
||||
defaultValue: defaultOptions.showHeader, |
||||
}) |
||||
.addRadio({ |
||||
path: 'cellHeight', |
||||
name: 'Cell height', |
||||
defaultValue: defaultOptions.cellHeight, |
||||
settings: { |
||||
options: [ |
||||
{ value: TableCellHeight.Sm, label: 'Small' }, |
||||
{ value: TableCellHeight.Md, label: 'Medium' }, |
||||
{ value: TableCellHeight.Lg, label: 'Large' }, |
||||
], |
||||
}, |
||||
}) |
||||
.addBooleanSwitch({ |
||||
path: 'footer.show', |
||||
category: [footerCategory], |
||||
name: 'Show table footer', |
||||
defaultValue: defaultOptions.footer?.show, |
||||
}) |
||||
.addCustomEditor({ |
||||
id: 'footer.reducer', |
||||
category: [footerCategory], |
||||
path: 'footer.reducer', |
||||
name: 'Calculation', |
||||
description: 'Choose a reducer function / calculation', |
||||
editor: standardEditorsRegistry.get('stats-picker').editor, |
||||
defaultValue: [ReducerID.sum], |
||||
showIf: (cfg) => cfg.footer?.show, |
||||
}) |
||||
.addBooleanSwitch({ |
||||
path: 'footer.countRows', |
||||
category: [footerCategory], |
||||
name: 'Count rows', |
||||
description: 'Display a single count for all data rows', |
||||
defaultValue: defaultOptions.footer?.countRows, |
||||
showIf: (cfg) => cfg.footer?.reducer?.length === 1 && cfg.footer?.reducer[0] === ReducerID.count, |
||||
}) |
||||
.addMultiSelect({ |
||||
path: 'footer.fields', |
||||
category: [footerCategory], |
||||
name: 'Fields', |
||||
description: 'Select the fields that should be calculated', |
||||
settings: { |
||||
allowCustomValue: false, |
||||
options: [], |
||||
placeholder: 'All Numeric Fields', |
||||
getOptions: async (context: FieldOverrideContext) => { |
||||
const options = []; |
||||
if (context && context.data && context.data.length > 0) { |
||||
const frame = context.data[0]; |
||||
for (const field of frame.fields) { |
||||
if (field.type === FieldType.number) { |
||||
const name = getFieldDisplayName(field, frame, context.data); |
||||
const value = field.name; |
||||
options.push({ value, label: name }); |
||||
} |
||||
} |
||||
} |
||||
return options; |
||||
}, |
||||
}, |
||||
defaultValue: '', |
||||
showIf: (cfg) => cfg.footer?.show && !cfg.footer?.countRows, |
||||
}) |
||||
.addCustomEditor({ |
||||
id: 'footer.enablePagination', |
||||
path: 'footer.enablePagination', |
||||
name: 'Enable pagination', |
||||
editor: PaginationEditor, |
||||
}); |
||||
}) |
||||
.setSuggestionsSupplier(new TableSuggestionsSupplier()); |
@ -0,0 +1,55 @@ |
||||
// Copyright 2021 Grafana Labs |
||||
// |
||||
// Licensed under the Apache License, Version 2.0 (the "License"); |
||||
// you may not use this file except in compliance with the License. |
||||
// You may obtain a copy of the License at |
||||
// |
||||
// http://www.apache.org/licenses/LICENSE-2.0 |
||||
// |
||||
// Unless required by applicable law or agreed to in writing, software |
||||
// distributed under the License is distributed on an "AS IS" BASIS, |
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
// See the License for the specific language governing permissions and |
||||
// limitations under the License. |
||||
|
||||
package grafanaplugin |
||||
|
||||
import ( |
||||
ui "github.com/grafana/grafana/packages/grafana-schema/src/common" |
||||
) |
||||
|
||||
composableKinds: PanelCfg: { |
||||
maturity: "experimental" |
||||
lineage: { |
||||
schemas: [{ |
||||
version: [0, 0] |
||||
schema: { |
||||
Options: { |
||||
// Represents the index of the selected frame |
||||
frameIndex: number | *0 |
||||
// Controls whether the panel should show the header |
||||
showHeader: bool | *true |
||||
// Controls whether the header should show icons for the column types |
||||
showTypeIcons?: bool | *false |
||||
// Used to control row sorting |
||||
sortBy?: [...ui.TableSortByFieldState] |
||||
// Controls footer options |
||||
footer?: ui.TableFooterOptions | *{ |
||||
// Controls whether the footer should be shown |
||||
show: false |
||||
// Controls whether the footer should show the total number of rows on Count calculation |
||||
countRows: false |
||||
// Represents the selected calculations |
||||
reducer: [] |
||||
} |
||||
// Controls the height of the rows |
||||
cellHeight?: ui.TableCellHeight & (*"sm" | _) |
||||
} @cuetsy(kind="interface") |
||||
FieldConfig: { |
||||
ui.TableFieldOptions |
||||
} @cuetsy(kind="interface") |
||||
} |
||||
}] |
||||
lenses: [] |
||||
} |
||||
} |
@ -0,0 +1,62 @@ |
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// public/app/plugins/gen.go
|
||||
// Using jennies:
|
||||
// TSTypesJenny
|
||||
// PluginTsTypesJenny
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
import * as ui from '@grafana/schema'; |
||||
|
||||
export interface Options { |
||||
/** |
||||
* Controls the height of the rows |
||||
*/ |
||||
cellHeight?: ui.TableCellHeight; |
||||
/** |
||||
* Controls footer options |
||||
*/ |
||||
footer?: ui.TableFooterOptions; |
||||
/** |
||||
* Represents the index of the selected frame |
||||
*/ |
||||
frameIndex: number; |
||||
/** |
||||
* Controls whether the panel should show the header |
||||
*/ |
||||
showHeader: boolean; |
||||
/** |
||||
* Controls whether the header should show icons for the column types |
||||
*/ |
||||
showTypeIcons?: boolean; |
||||
/** |
||||
* Used to control row sorting |
||||
*/ |
||||
sortBy?: Array<ui.TableSortByFieldState>; |
||||
} |
||||
|
||||
export const defaultOptions: Partial<Options> = { |
||||
cellHeight: ui.TableCellHeight.Sm, |
||||
footer: { |
||||
/** |
||||
* Controls whether the footer should be shown |
||||
*/ |
||||
show: false, |
||||
/** |
||||
* Controls whether the footer should show the total number of rows on Count calculation |
||||
*/ |
||||
countRows: false, |
||||
/** |
||||
* Represents the selected calculations |
||||
*/ |
||||
reducer: [], |
||||
}, |
||||
frameIndex: 0, |
||||
showHeader: true, |
||||
showTypeIcons: false, |
||||
sortBy: [], |
||||
}; |
||||
|
||||
export interface FieldConfig extends ui.TableFieldOptions {} |
@ -0,0 +1,25 @@ |
||||
{ |
||||
"type": "panel", |
||||
"name": "Table", |
||||
"id": "table", |
||||
"state": "beta", |
||||
|
||||
"info": { |
||||
"description": "Supports many column styles", |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
}, |
||||
"logos": { |
||||
"small": "img/icn-table-panel.svg", |
||||
"large": "img/icn-table-panel.svg" |
||||
}, |
||||
"links": [ |
||||
{ "name": "Raise issue", "url": "https://github.com/grafana/grafana/issues/new" }, |
||||
{ |
||||
"name": "Documentation", |
||||
"url": "https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/table/" |
||||
} |
||||
] |
||||
} |
||||
} |
@ -0,0 +1,37 @@ |
||||
import { VisualizationSuggestionsBuilder } from '@grafana/data'; |
||||
import { TableFieldOptions } from '@grafana/schema'; |
||||
import { SuggestionName } from 'app/types/suggestions'; |
||||
|
||||
import { Options } from './panelcfg.gen'; |
||||
|
||||
export class TableSuggestionsSupplier { |
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const list = builder.getListAppender<Options, TableFieldOptions>({ |
||||
name: SuggestionName.Table, |
||||
pluginId: 'table', |
||||
options: {}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: {}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
cardOptions: { |
||||
previewModifier: (s) => { |
||||
s.fieldConfig!.defaults.custom!.minWidth = 50; |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
// If there are not data suggest table anyway but use icon instead of real preview
|
||||
if (builder.dataSummary.fieldCount === 0) { |
||||
list.append({ |
||||
cardOptions: { |
||||
imgSrc: 'public/app/plugins/panel/table/img/icn-table-panel.svg', |
||||
}, |
||||
}); |
||||
} else { |
||||
list.append({}); |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue