TableNG: Restructure panel implementation swapping (#102956)

pull/102905/head
Leon Sorokin 3 months ago committed by GitHub
parent 488581fcc1
commit 7e3efb3df2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 15
      .betterer.results
  2. 10
      packages/grafana-ui/src/components/Table/Table.test.tsx
  3. 9
      packages/grafana-ui/src/components/Table/Table.tsx
  4. 2
      packages/grafana-ui/src/components/Table/TableRT/Table.tsx
  5. 4
      packages/grafana-ui/src/components/Table/reducer.ts
  6. 18
      packages/grafana-ui/src/components/Table/types.ts
  7. 1
      packages/grafana-ui/src/components/index.ts
  8. 2
      packages/grafana-ui/src/unstable.ts
  9. 10
      public/app/features/plugins/built_in_plugins.ts
  10. 2
      public/app/plugins/panel/table/TablePanel.tsx
  11. 15
      public/app/plugins/panel/table/table-new/PaginationEditor.tsx
  12. 9
      public/app/plugins/panel/table/table-new/README.md
  13. 100
      public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx
  14. 189
      public/app/plugins/panel/table/table-new/TablePanel.tsx
  15. 82
      public/app/plugins/panel/table/table-new/__snapshots__/migrations.test.ts.snap
  16. 30
      public/app/plugins/panel/table/table-new/cells/AutoCellOptionsEditor.tsx
  17. 51
      public/app/plugins/panel/table/table-new/cells/BarGaugeCellOptionsEditor.tsx
  18. 61
      public/app/plugins/panel/table/table-new/cells/ColorBackgroundCellOptionsEditor.tsx
  19. 33
      public/app/plugins/panel/table/table-new/cells/ImageCellOptionsEditor.tsx
  20. 94
      public/app/plugins/panel/table/table-new/cells/SparklineCellOptionsEditor.tsx
  21. 1
      public/app/plugins/panel/table/table-new/img/icn-table-panel.svg
  22. 364
      public/app/plugins/panel/table/table-new/migrations.test.ts
  23. 299
      public/app/plugins/panel/table/table-new/migrations.ts
  24. 187
      public/app/plugins/panel/table/table-new/module.tsx
  25. 55
      public/app/plugins/panel/table/table-new/panelcfg.cue
  26. 62
      public/app/plugins/panel/table/table-new/panelcfg.gen.ts
  27. 25
      public/app/plugins/panel/table/table-new/plugin.json
  28. 37
      public/app/plugins/panel/table/table-new/suggestions.ts

@ -2037,9 +2037,6 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
"public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
@ -3777,6 +3774,9 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/explore/Logs/LogsMetaRow.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/explore/Logs/LogsSamplePanel.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
@ -5995,6 +5995,15 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/plugins/panel/table/table-new/cells/SparklineCellOptionsEditor.tsx:5381": [
[0, 0, 0, "\'VerticalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
],
"public/app/plugins/panel/table/table-new/migrations.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/plugins/panel/text/textPanelMigrationHandler.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

@ -6,7 +6,7 @@ import { applyFieldOverrides, createTheme, DataFrame, FieldType, toDataFrame } f
import { Icon } from '../Icon/Icon';
import { Table } from './TableRT/Table';
import { CustomHeaderRendererProps, BaseTableProps } from './types';
import { CustomHeaderRendererProps, TableRTProps } from './types';
// mock transition styles to ensure consistent behaviour in unit tests
jest.mock('@floating-ui/react', () => ({
@ -101,11 +101,11 @@ function applyOverrides(dataFrame: DataFrame) {
return dataFrames[0];
}
function getTestContext(propOverrides: Partial<BaseTableProps> = {}) {
function getTestContext(propOverrides: Partial<TableRTProps> = {}) {
const onSortByChange = jest.fn();
const onCellFilterAdded = jest.fn();
const onColumnResize = jest.fn();
const props: BaseTableProps = {
const props: TableRTProps = {
ariaLabel: 'aria-label',
data: getDataFrame(fullDataFrame),
height: 600,
@ -415,7 +415,7 @@ describe('Table', () => {
const onSortByChange = jest.fn();
const onCellFilterAdded = jest.fn();
const onColumnResize = jest.fn();
const props: BaseTableProps = {
const props: TableRTProps = {
ariaLabel: 'aria-label',
data: getDataFrame(fullDataFrame),
height: 600,
@ -557,7 +557,7 @@ describe('Table', () => {
const onSortByChange = jest.fn();
const onCellFilterAdded = jest.fn();
const onColumnResize = jest.fn();
const props: BaseTableProps = {
const props: TableRTProps = {
ariaLabel: 'aria-label',
data: getDataFrame(fullDataFrame),
height: 600,

@ -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';

@ -21,7 +21,7 @@ import { Pagination } from '../../Pagination/Pagination';
import { TableCellInspector } from '../TableCellInspector';
import { useFixScrollbarContainer, useResetVariableListSizeCache } from '../hooks';
import { getInitialState, useTableStateReducer } from '../reducer';
import { FooterItem, GrafanaTableState, InspectCell, BaseTableProps as Props } from '../types';
import { FooterItem, GrafanaTableState, InspectCell, TableRTProps as Props } from '../types';
import {
getColumns,
sortCaseInsensitive,

@ -7,7 +7,7 @@ import {
GrafanaTableColumn,
GrafanaTableState,
TableStateReducerProps,
GeneralTableProps,
TableRTProps,
} from './types';
export interface ActionType {
@ -69,7 +69,7 @@ export function useTableStateReducer({ onColumnResize, onSortByChange, data }: T
}
export function getInitialState(
initialSortBy: GeneralTableProps['initialSortBy'],
initialSortBy: TableRTProps['initialSortBy'],
columns: GrafanaTableColumn[]
): Partial<GrafanaTableState> {
const state: Partial<GrafanaTableState> = {};

@ -97,7 +97,7 @@ export interface TableStateReducerProps {
}
// export interface Props {
export interface BaseTableProps {
export interface TableRTProps {
ariaLabel?: string;
data: DataFrame;
width: number;
@ -126,22 +126,6 @@ export interface BaseTableProps {
replaceVariables?: InterpolateFunction;
}
export interface GeneralTableProps extends BaseTableProps {
// Should the next generation table based off of react-data-grid be used
// 🗻 BIG 🗻 if true
useTableNg?: boolean;
}
/**
* Props for the react-data-grid based table.
*/
export interface TableNGProps extends BaseTableProps {}
/**
* Props for the react-table based table.
*/
export interface TableRTProps extends BaseTableProps {}
/**
* @alpha
* Props that will be passed to the TableCustomCellOptions.cellComponent when rendered.

@ -101,6 +101,7 @@ export {
type TableImageCellOptions,
type TableJsonViewCellOptions,
} from './Table/types';
export { TableInputCSV } from './TableInputCSV/TableInputCSV';
export { TabsBar } from './Tabs/TabsBar';
export { Tab, type TabProps } from './Tabs/Tab';

@ -10,3 +10,5 @@
*/
export * from './utils/skeleton';
export { TableNG } from './components/Table/TableNG/TableNG';

@ -1,3 +1,5 @@
import { config } from '@grafana/runtime';
const graphitePlugin = async () =>
await import(/* webpackChunkName: "graphitePlugin" */ 'app/plugins/datasource/graphite/module');
const cloudwatchPlugin = async () =>
@ -55,7 +57,13 @@ const stateTimelinePanel = async () =>
await import(/* webpackChunkName: "stateTimelinePanel" */ 'app/plugins/panel/state-timeline/module');
const statusHistoryPanel = async () =>
await import(/* webpackChunkName: "statusHistoryPanel" */ 'app/plugins/panel/status-history/module');
const tablePanel = async () => await import(/* webpackChunkName: "tablePanel" */ 'app/plugins/panel/table/module');
const tablePanel = async () => {
if (config.featureToggles.tableNextGen) {
return await import(/* webpackChunkName: "tableNewPanel" */ 'app/plugins/panel/table/table-new/module');
} else {
return await import(/* webpackChunkName: "tablePanel" */ 'app/plugins/panel/table/module');
}
};
const textPanel = async () => await import(/* webpackChunkName: "textPanel" */ 'app/plugins/panel/text/module');
const timeseriesPanel = async () =>
await import(/* webpackChunkName: "timeseriesPanel" */ 'app/plugins/panel/timeseries/module');

@ -34,7 +34,6 @@ export function TablePanel(props: Props) {
const hasFields = frames.some((frame) => frame.fields.length > 0);
const currentIndex = getCurrentFrameIndex(frames, options);
const main = frames[currentIndex];
const useTableNg = config.featureToggles.tableNextGen;
let tableHeight = height;
@ -69,7 +68,6 @@ export function TablePanel(props: Props) {
timeRange={timeRange}
enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair}
fieldConfig={fieldConfig}
useTableNg={useTableNg}
getActions={getCellActions}
replaceVariables={replaceVariables}
/>

@ -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',
},
}),
});

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 79.8 78.47"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:url(#linear-gradient-2);}.cls-3{fill:url(#linear-gradient-3);}.cls-4{fill:#84aff1;}.cls-5{fill:#3865ab;}</style><linearGradient id="linear-gradient" y1="6.25" x2="23.93" y2="6.25" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient><linearGradient id="linear-gradient-2" x1="55.87" y1="6.25" x2="79.8" y2="6.25" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-3" x1="27.93" y1="6.25" x2="51.87" y2="6.25" xlink:href="#linear-gradient"/></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M0,1V12.49H23.93V0H1A1,1,0,0,0,0,1Z"/><path class="cls-2" d="M55.87,12.49H79.8V1a1,1,0,0,0-1-1H55.87Z"/><rect class="cls-3" x="27.93" width="23.93" height="12.49"/><rect class="cls-4" x="27.93" y="16.49" width="23.93" height="12.49"/><rect class="cls-4" y="16.49" width="23.93" height="12.49"/><rect class="cls-4" x="55.87" y="16.49" width="23.93" height="12.49"/><rect class="cls-5" x="55.87" y="32.99" width="23.93" height="12.5"/><rect class="cls-5" x="27.93" y="32.99" width="23.93" height="12.5"/><rect class="cls-5" y="32.99" width="23.93" height="12.5"/><rect class="cls-4" x="27.93" y="49.48" width="23.93" height="12.49"/><rect class="cls-4" x="55.87" y="49.48" width="23.93" height="12.49"/><rect class="cls-4" y="49.48" width="23.93" height="12.49"/><rect class="cls-5" x="27.93" y="65.98" width="23.93" height="12.49"/><path class="cls-5" d="M79.8,77.47V66H55.87V78.47H78.8A1,1,0,0,0,79.8,77.47Z"/><path class="cls-5" d="M23.93,78.47V66H0V77.47a1,1,0,0,0,1,1Z"/></g></g></svg>

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…
Cancel
Save