From 03cd416d9e9bb05db7687fafc003856c8a7296e1 Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Thu, 25 Nov 2021 04:51:08 -0500 Subject: [PATCH] PanelEditor: Present actionable suggestions when panel cannot visualize current data (#42083) (#42271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PanelDataError: Show actions when current panel cannot visualize data * Fixed so that suggestions tab is opened from action * Cleanup * Fixed tests * Fix tests * Fixing tests * Fixed ts issues (cherry picked from commit 070344943c3cd6acc7c830d455e40d8baa68637a) Co-authored-by: Torkel Ödegaard --- .../src/components/PanelDataErrorView.tsx | 43 ++++++ packages/grafana-runtime/src/index.ts | 3 +- public/app/app.ts | 5 +- public/app/core/components/CardButton.tsx | 52 +++++++ public/app/core/constants.ts | 2 + .../AddPanelWidget/AddPanelWidget.test.tsx | 2 +- .../AddPanelWidget/AddPanelWidget.tsx | 134 +++++++----------- .../PanelEditor/VisualizationSelectPane.tsx | 28 ++-- .../dashboard/components/PanelEditor/types.ts | 6 + .../panel/components/CannotVisualizeData.tsx | 51 ------- .../panel/components/PanelDataErrorView.tsx | 109 ++++++++++++++ .../panel/candlestick/CandlestickPanel.tsx | 14 +- .../plugins/panel/candlestick/fields.test.ts | 11 +- .../app/plugins/panel/candlestick/fields.ts | 23 ++- .../app/plugins/panel/candlestick/module.tsx | 20 +-- .../plugins/panel/candlestick/suggestions.ts | 2 +- .../panel/timeseries/TimeSeriesPanel.tsx | 12 +- .../plugins/panel/timeseries/utils.test.ts | 23 ++- public/app/plugins/panel/timeseries/utils.ts | 54 +++---- 19 files changed, 358 insertions(+), 236 deletions(-) create mode 100644 packages/grafana-runtime/src/components/PanelDataErrorView.tsx create mode 100644 public/app/core/components/CardButton.tsx delete mode 100644 public/app/features/panel/components/CannotVisualizeData.tsx create mode 100644 public/app/features/panel/components/PanelDataErrorView.tsx diff --git a/packages/grafana-runtime/src/components/PanelDataErrorView.tsx b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx new file mode 100644 index 00000000000..aac2f9ee304 --- /dev/null +++ b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { PanelData } from '@grafana/data'; + +/** + * Describes the properties that can be passed to the PanelDataErrorView. + * + * @alpha + */ +export interface PanelDataErrorViewProps { + message?: string; + panelId: number; + data: PanelData; + needsTimeField?: boolean; + needsNumberField?: boolean; + // suggestions?: VisualizationSuggestion[]; <<< for sure optional +} + +/** + * Simplified type with defaults that describes the PanelDataErrorView. + * + * @internal + */ +export type PanelDataErrorViewType = React.ComponentType; + +/** + * PanelDataErrorView allows panels to show a consistent error message when + * the result structure does not meet expected criteria + * + * @alpha + */ +export let PanelDataErrorView: PanelDataErrorViewType = ({ message }) => { + return
Unable to render data: {message}.
; +}; + +/** + * Used to bootstrap the PanelDataErrorView during application start so the + * PanelDataErrorView is exposed via runtime. + * + * @internal + */ +export function setPanelDataErrorView(renderer: PanelDataErrorViewType) { + PanelDataErrorView = renderer; +} diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 6d30d2d1cf3..1c3f648a3fb 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -22,7 +22,8 @@ export { BackendDataSourceResponse, DataResponse, } from './utils/queryResponse'; +export { PanelRenderer, PanelRendererProps } from './components/PanelRenderer'; +export { PanelDataErrorView, PanelDataErrorViewProps } from './components/PanelDataErrorView'; export { toDataQueryError } from './utils/toDataQueryError'; -export { PanelRenderer, PanelRendererProps, PanelRendererType, setPanelRenderer } from './components/PanelRenderer'; export { setQueryRunnerFactory, createQueryRunner, QueryRunnerFactory } from './services/QueryRunner'; export { DataSourcePicker, DataSourcePickerProps, DataSourcePickerState } from './components/DataSourcePicker'; diff --git a/public/app/app.ts b/public/app/app.ts index 17ee23e94bd..9561c683cfd 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -33,7 +33,6 @@ import { setDataSourceSrv, setEchoSrv, setLocationSrv, - setPanelRenderer, setQueryRunnerFactory, } from '@grafana/runtime'; import { Echo } from './core/services/echo/Echo'; @@ -60,6 +59,9 @@ import { ApplicationInsightsBackend } from './core/services/echo/backends/analyt import { RudderstackBackend } from './core/services/echo/backends/analytics/RudderstackBackend'; import { getAllOptionEditors } from './core/components/editors/registry'; import { backendSrv } from './core/services/backend_srv'; +import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; +import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView'; +import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { DatasourceSrv } from './features/plugins/datasource_srv'; import { AngularApp } from './angular'; import { ModalManager } from './core/services/ModalManager'; @@ -93,6 +95,7 @@ export class GrafanaApp { setLocale(config.bootData.user.locale); setWeekStart(config.bootData.user.weekStart); setPanelRenderer(PanelRenderer); + setPanelDataErrorView(PanelDataErrorView); setLocationSrv(locationService); setTimeZoneResolver(() => config.bootData.user.timezone); // Important that extension reducers are initialized before store diff --git a/public/app/core/components/CardButton.tsx b/public/app/core/components/CardButton.tsx new file mode 100644 index 00000000000..45d87b600c4 --- /dev/null +++ b/public/app/core/components/CardButton.tsx @@ -0,0 +1,52 @@ +import React, { HTMLAttributes } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, IconName, useStyles2 } from '@grafana/ui'; + +interface Props extends HTMLAttributes { + icon: IconName; + onClick: () => void; + children: React.ReactNode; +} + +export const CardButton = React.forwardRef( + ({ icon, children, onClick, ...restProps }, ref) => { + const styles = useStyles2(getStyles); + + return ( + + ); + } +); + +CardButton.displayName = 'CardButton'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + action: css` + display: flex; + flex-direction: column; + height: 100%; + + justify-self: center; + cursor: pointer; + background: ${theme.colors.background.secondary}; + border-radius: ${theme.shape.borderRadius(1)}; + color: ${theme.colors.text.primary}; + border: unset; + width: 100%; + display: flex; + + justify-content: center; + align-items: center; + text-align: center; + + &:hover { + background: ${theme.colors.emphasize(theme.colors.background.secondary)}; + } + `, + }; +}; diff --git a/public/app/core/constants.ts b/public/app/core/constants.ts index 30a96fb558f..66417d663af 100644 --- a/public/app/core/constants.ts +++ b/public/app/core/constants.ts @@ -15,3 +15,5 @@ export const PANEL_BORDER = 2; export const EDIT_PANEL_ID = 23763571993; export const DEFAULT_PER_PAGE_PAGINATION = 40; + +export const LS_VISUALIZATION_SELECT_TAB_KEY = 'VisualizationSelectPane.ListMode'; diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx index 623c103bd7b..4ce64244bd9 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx @@ -22,7 +22,7 @@ describe('AddPanelWidget', () => { it('should render the add panel actions', () => { getTestContext(); - expect(screen.getByText(/Add an empty panel/i)).toBeInTheDocument(); + expect(screen.getByText(/Add a new panel/i)).toBeInTheDocument(); expect(screen.getByText(/Add a new row/i)).toBeInTheDocument(); expect(screen.getByText(/Add a panel from the panel library/i)).toBeInTheDocument(); }); diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index bed9772068c..a059a67818d 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -4,7 +4,7 @@ import { css, cx, keyframes } from '@emotion/css'; import { chain, cloneDeep, defaults, find, sortBy } from 'lodash'; import tinycolor from 'tinycolor2'; import { locationService, reportInteraction } from '@grafana/runtime'; -import { Icon, IconButton, styleMixins, useStyles2 } from '@grafana/ui'; +import { Icon, IconButton, useStyles2 } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; import { GrafanaTheme2 } from '@grafana/data'; @@ -19,6 +19,7 @@ import { LibraryPanelsSearch, LibraryPanelsSearchVariant, } from '../../../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch'; +import { CardButton } from 'app/core/components/CardButton'; export type PanelPluginInfo = { id: any; defaults: { gridPos: { w: any; h: any }; title: any } }; @@ -145,53 +146,48 @@ export const AddPanelWidgetUnconnected: React.FC = ({ panel, dashboard }) ) : (
-
-
{ - reportInteraction('Create new panel'); - onCreateNewPanel(); - }} - aria-label={selectors.pages.AddDashboard.addNewPanel} - > - - Add an empty panel -
-
{ - reportInteraction('Create new row'); - onCreateNewRow(); - }} - aria-label={selectors.pages.AddDashboard.addNewRow} - > - - Add a new row -
-
-
-
{ - reportInteraction('Add a panel from the panel library'); - setAddPanelView(true); - }} + { + reportInteraction('Create new panel'); + onCreateNewPanel(); + }} + > + Add a new panel + + { + reportInteraction('Create new row'); + onCreateNewRow(); + }} + > + Add a new row + + { + reportInteraction('Add a panel from the panel library'); + setAddPanelView(true); + }} + > + Add a panel from the panel library + + {copiedPanelPlugins.length === 1 && ( + { + reportInteraction('Paste panel from clipboard'); + onPasteCopiedPanel(copiedPanelPlugins[0]); + }} > - - Add a panel from the panel library -
- {copiedPanelPlugins.length === 1 && ( -
{ - reportInteraction('Paste panel from clipboard'); - onPasteCopiedPanel(copiedPanelPlugins[0]); - }} - > - - Paste panel from clipboard -
- )} -
+ Paste panel from clipboard + + )}
)} @@ -254,44 +250,18 @@ const getStyles = (theme: GrafanaTheme2) => { box-shadow: 0 0 0 2px black, 0 0 0px 4px #1f60c4; animation: ${pulsate} 2s ease infinite; `, - rowGap: css` - margin-left: ${theme.spacing(1)}; - `, - columnGap: css` - margin-bottom: ${theme.spacing(1)}; - `, - actionsRow: css` - display: flex; - flex-direction: row; - height: 100%; - - > div { - justify-self: center; - cursor: pointer; - background: ${theme.colors.background.secondary}; - border-radius: ${theme.shape.borderRadius(1)}; - color: ${theme.colors.text.primary}; - width: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - text-align: center; - - &:hover { - background: ${styleMixins.hoverColor(theme.colors.background.secondary, theme)}; - } - - &:hover > #book-icon { - background: linear-gradient(#f05a28 30%, #fbca0a 99%); - } - } - `, actionsWrapper: css` - display: flex; - flex-direction: column; - padding: ${theme.spacing(0, 1, 1, 1)}; height: 100%; + display: grid; + grid-template-columns: repeat(2, 1fr); + column-gap: ${theme.spacing(1)}; + row-gap: ${theme.spacing(1)}; + padding: ${theme.spacing(0, 1, 1, 1)}; + + // This is to make the last action full width (if by itself) + & > div:nth-child(2n-1):nth-last-of-type(1) { + grid-column: span 2; + } `, headerRow: css` display: flex; diff --git a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx index a0b953cb568..0feb1ddf4d6 100644 --- a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx +++ b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx @@ -14,6 +14,8 @@ import { getPanelPluginWithFallback } from '../../state/selectors'; import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types'; import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions'; import { useLocalStorage } from 'react-use'; +import { VisualizationSelectPaneTab } from './types'; +import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants'; interface Props { panel: PanelModel; @@ -23,7 +25,11 @@ interface Props { export const VisualizationSelectPane: FC = ({ panel, data }) => { const plugin = useSelector(getPanelPluginWithFallback(panel.type)); const [searchQuery, setSearchQuery] = useState(''); - const [listMode, setListMode] = useLocalStorage(`VisualizationSelectPane.ListMode`, ListMode.Visualizations); + const [listMode, setListMode] = useLocalStorage( + LS_VISUALIZATION_SELECT_TAB_KEY, + VisualizationSelectPaneTab.Visualizations + ); + const dispatch = useDispatch(); const styles = useStyles(getStyles); const searchRef = useRef(null); @@ -70,12 +76,12 @@ export const VisualizationSelectPane: FC = ({ panel, data }) => { return null; } - const radioOptions: Array> = [ - { label: 'Visualizations', value: ListMode.Visualizations }, - { label: 'Suggestions', value: ListMode.Suggestions }, + const radioOptions: Array> = [ + { label: 'Visualizations', value: VisualizationSelectPaneTab.Visualizations }, + { label: 'Suggestions', value: VisualizationSelectPaneTab.Suggestions }, { label: 'Library panels', - value: ListMode.LibraryPanels, + value: VisualizationSelectPaneTab.LibraryPanels, description: 'Reusable panels you can share between multiple dashboards.', }, ]; @@ -107,7 +113,7 @@ export const VisualizationSelectPane: FC = ({ panel, data }) => {
- {listMode === ListMode.Visualizations && ( + {listMode === VisualizationSelectPaneTab.Visualizations && ( = ({ panel, data }) => { onClose={() => {}} /> )} - {listMode === ListMode.Suggestions && ( + {listMode === VisualizationSelectPaneTab.Suggestions && ( = ({ panel, data }) => { onClose={() => {}} /> )} - {listMode === ListMode.LibraryPanels && ( + {listMode === VisualizationSelectPaneTab.LibraryPanels && ( )}
@@ -136,12 +142,6 @@ export const VisualizationSelectPane: FC = ({ panel, data }) => { ); }; -enum ListMode { - Visualizations, - LibraryPanels, - Suggestions, -} - VisualizationSelectPane.displayName = 'VisualizationSelectPane'; const getStyles = (theme: GrafanaTheme) => { diff --git a/public/app/features/dashboard/components/PanelEditor/types.ts b/public/app/features/dashboard/components/PanelEditor/types.ts index 7ca735ded5b..6e4b2306637 100644 --- a/public/app/features/dashboard/components/PanelEditor/types.ts +++ b/public/app/features/dashboard/components/PanelEditor/types.ts @@ -66,3 +66,9 @@ export interface OptionPaneItemOverrideInfo { tooltip: string; description: string; } + +export enum VisualizationSelectPaneTab { + Visualizations, + LibraryPanels, + Suggestions, +} diff --git a/public/app/features/panel/components/CannotVisualizeData.tsx b/public/app/features/panel/components/CannotVisualizeData.tsx deleted file mode 100644 index 51489b95d07..00000000000 --- a/public/app/features/panel/components/CannotVisualizeData.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { GrafanaTheme2, VisualizationSuggestion } from '@grafana/data'; -import { useStyles2 } from '../../../../../packages/grafana-ui/src'; -import { css } from '@emotion/css'; - -interface Props { - message: string; - suggestions?: VisualizationSuggestion[]; -} - -export function CannotVisualizeData({ message, suggestions }: Props) { - const styles = useStyles2(getStyles); - - return ( -
-
{message}
- { - // suggestions && ( - //
- // {suggestions.map((suggestion, index) => ( - // - // ))} - //
- // ) - } -
- ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - wrapper: css` - display: flex; - align-items: center; - height: 100%; - width: 100%; - `, - message: css` - text-align: center; - color: $text-muted; - font-size: $font-size-lg; - width: 100%; - `, - }; -}; diff --git a/public/app/features/panel/components/PanelDataErrorView.tsx b/public/app/features/panel/components/PanelDataErrorView.tsx new file mode 100644 index 00000000000..d838257fcf0 --- /dev/null +++ b/public/app/features/panel/components/PanelDataErrorView.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { CoreApp, GrafanaTheme2, PanelDataSummary, VisualizationSuggestionsBuilder } from '@grafana/data'; +import { usePanelContext, useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { PanelDataErrorViewProps } from '@grafana/runtime'; +import { CardButton } from 'app/core/components/CardButton'; +import { useDispatch } from 'react-redux'; +import { toggleVizPicker } from 'app/features/dashboard/components/PanelEditor/state/reducers'; +import { changePanelPlugin } from '../state/actions'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import store from 'app/core/store'; +import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants'; +import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types'; + +export function PanelDataErrorView(props: PanelDataErrorViewProps) { + const styles = useStyles2(getStyles); + const context = usePanelContext(); + const builder = new VisualizationSuggestionsBuilder(props.data); + const { dataSummary } = builder; + const message = getMessageFor(props, dataSummary); + const dispatch = useDispatch(); + + const openVizPicker = () => { + store.setObject(LS_VISUALIZATION_SELECT_TAB_KEY, VisualizationSelectPaneTab.Suggestions); + dispatch(toggleVizPicker(true)); + }; + + const switchToTable = () => { + const panel = getDashboardSrv().getCurrent()?.getPanelById(props.panelId); + if (!panel) { + return; + } + + dispatch( + changePanelPlugin({ + panel, + pluginId: 'table', + }) + ); + }; + + return ( +
+
{message}
+ {context.app === CoreApp.PanelEditor && dataSummary.hasData && ( +
+ + Switch to table + + + Open visualization suggestions + +
+ )} +
+ ); +} + +function getMessageFor( + { data, message, needsNumberField, needsTimeField }: PanelDataErrorViewProps, + dataSummary: PanelDataSummary +): string { + if (message) { + return message; + } + + if (!data.series || data.series.length === 0) { + return 'No data'; + } + + if (needsNumberField && !dataSummary.hasNumberField) { + return 'Data is missing a number field'; + } + + if (needsTimeField && !dataSummary.hasTimeField) { + return 'Data is missing a time field'; + } + + return 'Cannot visualize data'; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + width: '100%', + }), + message: css({ + textAlign: 'center', + color: theme.colors.text.secondary, + fontSize: theme.typography.size.lg, + width: '100%', + }), + actions: css({ + marginTop: theme.spacing(2), + display: 'flex', + height: '50%', + maxHeight: '150px', + columnGap: theme.spacing(1), + rowGap: theme.spacing(1), + width: '100%', + maxWidth: '600px', + }), + }; +}; diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index f1b432e2945..28fd8f6cfa3 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -18,11 +18,13 @@ import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBu import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; import { prepareCandlestickFields } from './fields'; import uPlot from 'uplot'; +import { PanelDataErrorView } from '@grafana/runtime'; interface CandlestickPanelProps extends PanelProps {} export const CandlestickPanel: React.FC = ({ data, + id, timeRange, timeZone, width, @@ -52,6 +54,10 @@ export const CandlestickPanel: React.FC = ({ tweakAxis, }; + if (!info) { + return doNothing; + } + // Un-encoding the already parsed special fields // This takes currently matched fields and saves the name so they can be looked up by name later // ¯\_(ツ)_/¯ someday this can make more sense! @@ -202,12 +208,8 @@ export const CandlestickPanel: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [options, data.structureRev]); - if (!info.frame || info.warn) { - return ( -
-

{info.warn ?? 'No data found in response'}

-
- ); + if (!info) { + return ; } const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); diff --git a/public/app/plugins/panel/candlestick/fields.test.ts b/public/app/plugins/panel/candlestick/fields.test.ts index a92b8ecaad4..01e8abd6e7b 100644 --- a/public/app/plugins/panel/candlestick/fields.test.ts +++ b/public/app/plugins/panel/candlestick/fields.test.ts @@ -23,7 +23,7 @@ describe('Candlestick data', () => { options, theme ); - expect(info.warn).toMatchInlineSnapshot(`"Data does not have a time field"`); + expect(info).toBeNull(); }); it('will match common names by default', () => { @@ -42,8 +42,7 @@ describe('Candlestick data', () => { options, theme ); - expect(info.warn).toBeUndefined(); - expect(info.names).toMatchInlineSnapshot(` + expect(info?.names).toMatchInlineSnapshot(` Object { "close": "Next open", "high": "MAX", @@ -72,7 +71,8 @@ describe('Candlestick data', () => { ], options, theme - ); + )!; + expect(info.open).toBeDefined(); expect(info.open).toEqual(info.high); expect(info.open).toEqual(info.low); @@ -114,7 +114,8 @@ describe('Candlestick data', () => { ], options, theme - ); + )!; + expect(info.open!.values.toArray()).toEqual([1, 1, 2, 3, 4]); expect(info.close!.values.toArray()).toEqual([1, 2, 3, 4, 5]); }); diff --git a/public/app/plugins/panel/candlestick/fields.ts b/public/app/plugins/panel/candlestick/fields.ts index a1f57d92c96..b81b98af79b 100644 --- a/public/app/plugins/panel/candlestick/fields.ts +++ b/public/app/plugins/panel/candlestick/fields.ts @@ -59,8 +59,6 @@ export const candlestickFieldsInfo: Record f.type === FieldType.time); + if (timeIndex < 0) { - data.warn = 'Missing time field'; - data.noTimeField = true; - return data; + return null; } // Find the known fields diff --git a/public/app/plugins/panel/candlestick/module.tsx b/public/app/plugins/panel/candlestick/module.tsx index c1d88cf1c18..c7cf322dc3e 100644 --- a/public/app/plugins/panel/candlestick/module.tsx +++ b/public/app/plugins/panel/candlestick/module.tsx @@ -43,18 +43,22 @@ const numericFieldFilter = (f: Field) => f.type === FieldType.number; function addFieldPicker( builder: PanelOptionsEditorBuilder, info: FieldPickerInfo, - data: CandlestickData + data: CandlestickData | null ) { - const current = data[info.key] as Field; let placeholderText = 'Auto '; - if (current?.config) { - placeholderText += '= ' + getFieldDisplayName(current); - if (current === data?.open && info.key !== 'open') { - placeholderText += ` (${info.defaults.join(',')})`; + if (data) { + const current = data[info.key] as Field; + + if (current?.config) { + placeholderText += '= ' + getFieldDisplayName(current); + + if (current === data?.open && info.key !== 'open') { + placeholderText += ` (${info.defaults.join(',')})`; + } + } else { + placeholderText += `(${info.defaults.join(',')})`; } - } else { - placeholderText += `(${info.defaults.join(',')})`; } builder.addFieldNamePicker({ diff --git a/public/app/plugins/panel/candlestick/suggestions.ts b/public/app/plugins/panel/candlestick/suggestions.ts index 17c7b73b2d8..1aaa385ae92 100644 --- a/public/app/plugins/panel/candlestick/suggestions.ts +++ b/public/app/plugins/panel/candlestick/suggestions.ts @@ -19,7 +19,7 @@ export class CandlestickSuggestionsSupplier { } const info = prepareCandlestickFields(builder.data.series, defaultPanelOptions, config.theme2); - if (!info.open || info.warn || info.noTimeField) { + if (!info) { return; } diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index 56ed14fd73d..2a463322c97 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -11,6 +11,7 @@ import { prepareGraphableFields } from './utils'; import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin'; import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin'; import { config } from 'app/core/config'; +import { PanelDataErrorView } from '@grafana/runtime'; interface TimeSeriesPanelProps extends PanelProps {} @@ -24,6 +25,7 @@ export const TimeSeriesPanel: React.FC = ({ fieldConfig, onChangeTimeRange, replaceVariables, + id, }) => { const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, onSplitOpen } = usePanelContext(); @@ -31,14 +33,10 @@ export const TimeSeriesPanel: React.FC = ({ return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange }); }; - const { frames, warn } = useMemo(() => prepareGraphableFields(data?.series, config.theme2), [data]); + const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2), [data]); - if (!frames || warn) { - return ( -
-

{warn ?? 'No data found in response'}

-
- ); + if (!frames) { + return ; } const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); diff --git a/public/app/plugins/panel/timeseries/utils.test.ts b/public/app/plugins/panel/timeseries/utils.test.ts index a1544dd9297..0d2065a8bff 100644 --- a/public/app/plugins/panel/timeseries/utils.test.ts +++ b/public/app/plugins/panel/timeseries/utils.test.ts @@ -3,7 +3,7 @@ import { prepareGraphableFields } from './utils'; describe('prepare timeseries graph', () => { it('errors with no time fields', () => { - const frames = [ + const input = [ toDataFrame({ fields: [ { name: 'a', values: [1, 2, 3] }, @@ -11,12 +11,12 @@ describe('prepare timeseries graph', () => { ], }), ]; - const info = prepareGraphableFields(frames, createTheme()); - expect(info.warn).toEqual('Data does not have a time field'); + const frames = prepareGraphableFields(input, createTheme()); + expect(frames).toBeNull(); }); it('requires a number or boolean value', () => { - const frames = [ + const input = [ toDataFrame({ fields: [ { name: 'a', type: FieldType.time, values: [1, 2, 3] }, @@ -24,12 +24,12 @@ describe('prepare timeseries graph', () => { ], }), ]; - const info = prepareGraphableFields(frames, createTheme()); - expect(info.warn).toEqual('No graphable fields'); + const frames = prepareGraphableFields(input, createTheme()); + expect(frames).toBeNull(); }); it('will graph numbers and boolean values', () => { - const frames = [ + const input = [ toDataFrame({ fields: [ { name: 'a', type: FieldType.time, values: [1, 2, 3] }, @@ -39,10 +39,9 @@ describe('prepare timeseries graph', () => { ], }), ]; - const info = prepareGraphableFields(frames, createTheme()); - expect(info.warn).toBeUndefined(); + const frames = prepareGraphableFields(input, createTheme()); + const out = frames![0]; - const out = info.frames![0]; expect(out.fields.map((f) => f.name)).toEqual(['a', 'c', 'd']); const field = out.fields.find((f) => f.name === 'c'); @@ -66,9 +65,9 @@ describe('prepare timeseries graph', () => { { name: 'a', values: [-10, NaN, 10, -Infinity, +Infinity] }, ], }); - const result = prepareGraphableFields([df], createTheme()); + const frames = prepareGraphableFields([df], createTheme()); - const field = result.frames![0].fields.find((f) => f.name === 'a'); + const field = frames![0].fields.find((f) => f.name === 'a'); expect(field!.values.toArray()).toMatchInlineSnapshot(` Array [ -10, diff --git a/public/app/plugins/panel/timeseries/utils.ts b/public/app/plugins/panel/timeseries/utils.ts index ec59208ea96..35608db3feb 100644 --- a/public/app/plugins/panel/timeseries/utils.ts +++ b/public/app/plugins/panel/timeseries/utils.ts @@ -9,37 +9,32 @@ import { } from '@grafana/data'; import { GraphFieldConfig, LineInterpolation, StackingMode } from '@grafana/schema'; -export interface GraphableFieldsResult { - frames?: DataFrame[]; - warn?: string; - noTimeField?: boolean; -} - -// This will return a set of frames with only graphable values included -export function prepareGraphableFields(series: DataFrame[] | undefined, theme: GrafanaTheme2): GraphableFieldsResult { +/** + * Returns null if there are no graphable fields + */ +export function prepareGraphableFields(series: DataFrame[], theme: GrafanaTheme2): DataFrame[] | null { if (!series?.length) { - return { warn: 'No data in response' }; + return null; } let copy: Field; - let hasTimeseries = false; const frames: DataFrame[] = []; for (let frame of series) { - let isTimeseries = false; - let changed = false; const fields: Field[] = []; + let hasTimeField = false; + let hasValueField = false; + for (const field of frame.fields) { switch (field.type) { case FieldType.time: - isTimeseries = true; - hasTimeseries = true; + hasTimeField = true; fields.push(field); break; case FieldType.number: - changed = true; + hasValueField = true; copy = { ...field, values: new ArrayVector( @@ -60,7 +55,7 @@ export function prepareGraphableFields(series: DataFrame[] | undefined, theme: G fields.push(copy); break; // ok case FieldType.boolean: - changed = true; + hasValueField = true; const custom: GraphFieldConfig = field.config?.custom ?? {}; const config = { ...field.config, @@ -95,31 +90,20 @@ export function prepareGraphableFields(series: DataFrame[] | undefined, theme: G fields.push(copy); break; - default: - changed = true; } } - if (isTimeseries && fields.length > 1) { - hasTimeseries = true; - if (changed) { - frames.push({ - ...frame, - fields, - }); - } else { - frames.push(frame); - } + if (hasTimeField && hasValueField) { + frames.push({ + ...frame, + fields, + }); } } - if (!hasTimeseries) { - return { warn: 'Data does not have a time field', noTimeField: true }; + if (frames.length) { + return frames; } - if (!frames.length) { - return { warn: 'No graphable fields' }; - } - - return { frames }; + return null; }