From 7e3efb3df2b4074eb020624cbb552281a8c9733c Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Thu, 27 Mar 2025 14:15:06 -0500 Subject: [PATCH] TableNG: Restructure panel implementation swapping (#102956) --- .betterer.results | 15 +- .../src/components/Table/Table.test.tsx | 10 +- .../grafana-ui/src/components/Table/Table.tsx | 9 +- .../src/components/Table/TableRT/Table.tsx | 2 +- .../src/components/Table/reducer.ts | 4 +- .../grafana-ui/src/components/Table/types.ts | 18 +- packages/grafana-ui/src/components/index.ts | 1 + packages/grafana-ui/src/unstable.ts | 2 + .../app/features/plugins/built_in_plugins.ts | 10 +- public/app/plugins/panel/table/TablePanel.tsx | 2 - .../table/table-new/PaginationEditor.tsx | 15 + .../plugins/panel/table/table-new/README.md | 9 + .../table/table-new/TableCellOptionEditor.tsx | 100 +++++ .../panel/table/table-new/TablePanel.tsx | 189 +++++++++ .../__snapshots__/migrations.test.ts.snap | 82 ++++ .../table-new/cells/AutoCellOptionsEditor.tsx | 30 ++ .../cells/BarGaugeCellOptionsEditor.tsx | 51 +++ .../ColorBackgroundCellOptionsEditor.tsx | 61 +++ .../cells/ImageCellOptionsEditor.tsx | 33 ++ .../cells/SparklineCellOptionsEditor.tsx | 94 +++++ .../table/table-new/img/icn-table-panel.svg | 1 + .../panel/table/table-new/migrations.test.ts | 364 ++++++++++++++++++ .../panel/table/table-new/migrations.ts | 299 ++++++++++++++ .../plugins/panel/table/table-new/module.tsx | 187 +++++++++ .../panel/table/table-new/panelcfg.cue | 55 +++ .../panel/table/table-new/panelcfg.gen.ts | 62 +++ .../plugins/panel/table/table-new/plugin.json | 25 ++ .../panel/table/table-new/suggestions.ts | 37 ++ 28 files changed, 1728 insertions(+), 39 deletions(-) create mode 100644 public/app/plugins/panel/table/table-new/PaginationEditor.tsx create mode 100644 public/app/plugins/panel/table/table-new/README.md create mode 100644 public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx create mode 100644 public/app/plugins/panel/table/table-new/TablePanel.tsx create mode 100644 public/app/plugins/panel/table/table-new/__snapshots__/migrations.test.ts.snap create mode 100644 public/app/plugins/panel/table/table-new/cells/AutoCellOptionsEditor.tsx create mode 100644 public/app/plugins/panel/table/table-new/cells/BarGaugeCellOptionsEditor.tsx create mode 100644 public/app/plugins/panel/table/table-new/cells/ColorBackgroundCellOptionsEditor.tsx create mode 100644 public/app/plugins/panel/table/table-new/cells/ImageCellOptionsEditor.tsx create mode 100644 public/app/plugins/panel/table/table-new/cells/SparklineCellOptionsEditor.tsx create mode 100644 public/app/plugins/panel/table/table-new/img/icn-table-panel.svg create mode 100644 public/app/plugins/panel/table/table-new/migrations.test.ts create mode 100644 public/app/plugins/panel/table/table-new/migrations.ts create mode 100644 public/app/plugins/panel/table/table-new/module.tsx create mode 100644 public/app/plugins/panel/table/table-new/panelcfg.cue create mode 100644 public/app/plugins/panel/table/table-new/panelcfg.gen.ts create mode 100644 public/app/plugins/panel/table/table-new/plugin.json create mode 100644 public/app/plugins/panel/table/table-new/suggestions.ts diff --git a/.betterer.results b/.betterer.results index 6fec6826f22..d7d99266d8e 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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 ", "0"] ], - "public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with 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 or use t()", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], @@ -3777,6 +3774,9 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "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 ", "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"] ], diff --git a/packages/grafana-ui/src/components/Table/Table.test.tsx b/packages/grafana-ui/src/components/Table/Table.test.tsx index 1dc458fe404..f8078e9b27a 100644 --- a/packages/grafana-ui/src/components/Table/Table.test.tsx +++ b/packages/grafana-ui/src/components/Table/Table.test.tsx @@ -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 = {}) { +function getTestContext(propOverrides: Partial = {}) { 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, diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index 91c49c871df..a4093e7524b 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -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 ? : ; - return table; -} +export { Table } from './TableRT/Table'; diff --git a/packages/grafana-ui/src/components/Table/TableRT/Table.tsx b/packages/grafana-ui/src/components/Table/TableRT/Table.tsx index e0741f2d5d3..b18d22dfa09 100644 --- a/packages/grafana-ui/src/components/Table/TableRT/Table.tsx +++ b/packages/grafana-ui/src/components/Table/TableRT/Table.tsx @@ -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, diff --git a/packages/grafana-ui/src/components/Table/reducer.ts b/packages/grafana-ui/src/components/Table/reducer.ts index 13247ab71b4..08663c757d3 100644 --- a/packages/grafana-ui/src/components/Table/reducer.ts +++ b/packages/grafana-ui/src/components/Table/reducer.ts @@ -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 { const state: Partial = {}; diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index 0a5a24b0fc1..e2c5b5b8099 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -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. diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index af030e6a0fb..d4d12702bec 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/unstable.ts b/packages/grafana-ui/src/unstable.ts index 02938ddf2d2..f1f6e7ab14c 100644 --- a/packages/grafana-ui/src/unstable.ts +++ b/packages/grafana-ui/src/unstable.ts @@ -10,3 +10,5 @@ */ export * from './utils/skeleton'; + +export { TableNG } from './components/Table/TableNG/TableNG'; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 9f8ac9cda32..0b449003be4 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -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'); diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx index 5ec38ca084a..a5dfbf92c63 100644 --- a/public/app/plugins/panel/table/TablePanel.tsx +++ b/public/app/plugins/panel/table/TablePanel.tsx @@ -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} /> diff --git a/public/app/plugins/panel/table/table-new/PaginationEditor.tsx b/public/app/plugins/panel/table/table-new/PaginationEditor.tsx new file mode 100644 index 00000000000..0bfa20b565b --- /dev/null +++ b/public/app/plugins/panel/table/table-new/PaginationEditor.tsx @@ -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) { + const changeValue = (event: React.FormEvent | undefined) => { + if (event?.currentTarget.checked) { + context.options.footer.show = false; + } + onChange(event?.currentTarget.checked); + }; + + return ; +} diff --git a/public/app/plugins/panel/table/table-new/README.md b/public/app/plugins/panel/table/table-new/README.md new file mode 100644 index 00000000000..6f6656bfa29 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/README.md @@ -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/) diff --git a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx new file mode 100644 index 00000000000..39b2059d760 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx @@ -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 { + 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>({}); + + // Update display mode on change + const onCellTypeChange = (v: SelectableValue) => { + 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 ( +
+ + onChangeTableSelection(val, props)} /> +
+ + ); +} + +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, 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> = []; + const actionLookup = new Set(); + + 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', + }), +}; diff --git a/public/app/plugins/panel/table/table-new/__snapshots__/migrations.test.ts.snap b/public/app/plugins/panel/table/table-new/__snapshots__/migrations.test.ts.snap new file mode 100644 index 00000000000..e51e742e7d5 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/__snapshots__/migrations.test.ts.snap @@ -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": [], + }, + }, + ], +} +`; diff --git a/public/app/plugins/panel/table/table-new/cells/AutoCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/AutoCellOptionsEditor.tsx new file mode 100644 index 00000000000..5603832de95 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/cells/AutoCellOptionsEditor.tsx @@ -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) => { + // Handle row coloring changes + const onWrapTextChange = () => { + cellOptions.wrapText = !cellOptions.wrapText; + onChange(cellOptions); + }; + + const label = ( + + ); + + return ( + <> + + + + + ); +}; diff --git a/public/app/plugins/panel/table/table-new/cells/BarGaugeCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/BarGaugeCellOptionsEditor.tsx new file mode 100644 index 00000000000..57e6b9703ee --- /dev/null +++ b/public/app/plugins/panel/table/table-new/cells/BarGaugeCellOptionsEditor.tsx @@ -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; + +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 ( + + + + + + + + + ); +} + +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' }, +]; diff --git a/public/app/plugins/panel/table/table-new/cells/ColorBackgroundCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/ColorBackgroundCellOptionsEditor.tsx new file mode 100644 index 00000000000..0cd943b5cf6 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/cells/ColorBackgroundCellOptionsEditor.tsx @@ -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> = [ + { value: TableCellBackgroundDisplayMode.Basic, label: 'Basic' }, + { value: TableCellBackgroundDisplayMode.Gradient, label: 'Gradient' }, +]; + +export const ColorBackgroundCellOptionsEditor = ({ + cellOptions, + onChange, +}: TableCellEditorProps) => { + // 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 = ( + + ); + + return ( + <> + + + + + + + + + + + ); +}; diff --git a/public/app/plugins/panel/table/table-new/cells/ImageCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/ImageCellOptionsEditor.tsx new file mode 100644 index 00000000000..51e711cd367 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/cells/ImageCellOptionsEditor.tsx @@ -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) => { + const onAltChange = (e: FormEvent) => { + cellOptions.alt = e.currentTarget.value; + onChange(cellOptions); + }; + + const onTitleChange = (e: FormEvent) => { + cellOptions.title = e.currentTarget.value; + onChange(cellOptions); + }; + + return ( + <> + + + + + + + + + ); +}; diff --git a/public/app/plugins/panel/table/table-new/cells/SparklineCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/SparklineCellOptionsEditor.tsx new file mode 100644 index 00000000000..5bc554b7f5d --- /dev/null +++ b/public/app/plugins/panel/table/table-new/cells/SparklineCellOptionsEditor.tsx @@ -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 = [ + 'hideValue', + 'drawStyle', + 'lineInterpolation', + 'barAlignment', + 'lineWidth', + 'fillOpacity', + 'gradientMode', + 'lineStyle', + 'spanNulls', + 'showPoints', + 'pointSize', +]; + +function getChartCellConfig(cfg: GraphFieldConfig): SetFieldConfigOptionsArgs { + const graphFieldConfig = getGraphFieldConfig(cfg); + return { + ...graphFieldConfig, + useCustomConfig: (builder) => { + graphFieldConfig.useCustomConfig?.(builder); + builder.addBooleanSwitch({ + path: 'hideValue', + name: 'Hide value', + }); + }, + }; +} + +export const SparklineCellOptionsEditor = (props: TableCellEditorProps) => { + const { cellOptions, onChange } = props; + + const registry = useMemo(() => { + const config = getChartCellConfig(defaultSparklineCellConfig); + return createFieldConfigRegistry(config, 'ChartCell'); + }, []); + + const style = useStyles2(getStyles); + + const values = { ...defaultSparklineCellConfig, ...cellOptions }; + + return ( + + {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 ( + + onChange({ ...cellOptions, [path]: val })} + value={(isOptionKey(path, values) ? values[path] : undefined) ?? item.defaultValue} + item={item} + context={{ data: [] }} + /> + + ); + })} + + ); +}; + +// 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', + }, + }), +}); diff --git a/public/app/plugins/panel/table/table-new/img/icn-table-panel.svg b/public/app/plugins/panel/table/table-new/img/icn-table-panel.svg new file mode 100644 index 00000000000..21846a8d38f --- /dev/null +++ b/public/app/plugins/panel/table/table-new/img/icn-table-panel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/panel/table/table-new/migrations.test.ts b/public/app/plugins/panel/table/table-new/migrations.test.ts new file mode 100644 index 00000000000..a40120c8857 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/migrations.test.ts @@ -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'); + }); +}); diff --git a/public/app/plugins/panel/table/table-new/migrations.ts b/public/app/plugins/panel/table/table-new/migrations.ts new file mode 100644 index 00000000000..aa4c635682c --- /dev/null +++ b/public/app/plugins/panel/table/table-new/migrations.ts @@ -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): Partial => { + // 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>, + 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>, + 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); +}; diff --git a/public/app/plugins/panel/table/table-new/module.tsx b/public/app/plugins/panel/table/table-new/module.tsx new file mode 100644 index 00000000000..0d6466426da --- /dev/null +++ b/public/app/plugins/panel/table/table-new/module.tsx @@ -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(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({ + 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()); diff --git a/public/app/plugins/panel/table/table-new/panelcfg.cue b/public/app/plugins/panel/table/table-new/panelcfg.cue new file mode 100644 index 00000000000..214e854f4a4 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/panelcfg.cue @@ -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: [] + } +} diff --git a/public/app/plugins/panel/table/table-new/panelcfg.gen.ts b/public/app/plugins/panel/table/table-new/panelcfg.gen.ts new file mode 100644 index 00000000000..5b771ff2640 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/panelcfg.gen.ts @@ -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; +} + +export const defaultOptions: Partial = { + 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 {} diff --git a/public/app/plugins/panel/table/table-new/plugin.json b/public/app/plugins/panel/table/table-new/plugin.json new file mode 100644 index 00000000000..36fb974d930 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/plugin.json @@ -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/" + } + ] + } +} diff --git a/public/app/plugins/panel/table/table-new/suggestions.ts b/public/app/plugins/panel/table/table-new/suggestions.ts new file mode 100644 index 00000000000..a848257318f --- /dev/null +++ b/public/app/plugins/panel/table/table-new/suggestions.ts @@ -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({ + 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({}); + } + } +}