PanelEditor: Present actionable suggestions when panel cannot visualize current data (#42083)

* 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
This commit is contained in:
Torkel Ödegaard 2021-11-25 09:41:03 +01:00 committed by GitHub
parent 6a86758f3b
commit 070344943c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 358 additions and 236 deletions

View File

@ -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<PanelDataErrorViewProps>;
/**
* 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 <div>Unable to render data: {message}.</div>;
};
/**
* Used to bootstrap the PanelDataErrorView during application start so the
* PanelDataErrorView is exposed via runtime.
*
* @internal
*/
export function setPanelDataErrorView(renderer: PanelDataErrorViewType) {
PanelDataErrorView = renderer;
}

View File

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

View File

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

View File

@ -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<HTMLButtonElement> {
icon: IconName;
onClick: () => void;
children: React.ReactNode;
}
export const CardButton = React.forwardRef<HTMLButtonElement, Props>(
({ icon, children, onClick, ...restProps }, ref) => {
const styles = useStyles2(getStyles);
return (
<button {...restProps} className={styles.action} onClick={onClick}>
<Icon name={icon} size="xl" />
{children}
</button>
);
}
);
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)};
}
`,
};
};

View File

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

View File

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

View File

@ -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<Props> = ({ panel, dashboard })
<LibraryPanelsSearch onClick={onAddLibraryPanel} variant={LibraryPanelsSearchVariant.Tight} showPanelFilter />
) : (
<div className={styles.actionsWrapper}>
<div className={cx(styles.actionsRow, styles.columnGap)}>
<div
onClick={() => {
reportInteraction('Create new panel');
onCreateNewPanel();
}}
aria-label={selectors.pages.AddDashboard.addNewPanel}
>
<Icon name="file-blank" size="xl" />
Add an empty panel
</div>
<div
className={styles.rowGap}
onClick={() => {
reportInteraction('Create new row');
onCreateNewRow();
}}
aria-label={selectors.pages.AddDashboard.addNewRow}
>
<Icon name="wrap-text" size="xl" />
Add a new row
</div>
</div>
<div className={styles.actionsRow}>
<div
onClick={() => {
reportInteraction('Add a panel from the panel library');
setAddPanelView(true);
}}
<CardButton
icon="file-blank"
aria-label={selectors.pages.AddDashboard.addNewPanel}
onClick={() => {
reportInteraction('Create new panel');
onCreateNewPanel();
}}
>
Add a new panel
</CardButton>
<CardButton
icon="wrap-text"
aria-label={selectors.pages.AddDashboard.addNewRow}
onClick={() => {
reportInteraction('Create new row');
onCreateNewRow();
}}
>
Add a new row
</CardButton>
<CardButton
icon="book-open"
aria-label={selectors.pages.AddDashboard.addNewPanelLibrary}
onClick={() => {
reportInteraction('Add a panel from the panel library');
setAddPanelView(true);
}}
>
Add a panel from the panel library
</CardButton>
{copiedPanelPlugins.length === 1 && (
<CardButton
icon="clipboard-alt"
aria-label={selectors.pages.AddDashboard.addNewPanelLibrary}
onClick={() => {
reportInteraction('Paste panel from clipboard');
onPasteCopiedPanel(copiedPanelPlugins[0]);
}}
>
<Icon name="book-open" size="xl" />
Add a panel from the panel library
</div>
{copiedPanelPlugins.length === 1 && (
<div
className={styles.rowGap}
onClick={() => {
reportInteraction('Paste panel from clipboard');
onPasteCopiedPanel(copiedPanelPlugins[0]);
}}
>
<Icon name="clipboard-alt" size="xl" />
Paste panel from clipboard
</div>
)}
</div>
Paste panel from clipboard
</CardButton>
)}
</div>
)}
</div>
@ -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;

View File

@ -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<Props> = ({ 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<HTMLInputElement | null>(null);
@ -70,12 +76,12 @@ export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
return null;
}
const radioOptions: Array<SelectableValue<ListMode>> = [
{ label: 'Visualizations', value: ListMode.Visualizations },
{ label: 'Suggestions', value: ListMode.Suggestions },
const radioOptions: Array<SelectableValue<VisualizationSelectPaneTab>> = [
{ 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<Props> = ({ panel, data }) => {
<div className={styles.scrollWrapper}>
<CustomScrollbar autoHeightMin="100%">
<div className={styles.scrollContent}>
{listMode === ListMode.Visualizations && (
{listMode === VisualizationSelectPaneTab.Visualizations && (
<VizTypePicker
current={plugin.meta}
onChange={onVizChange}
@ -116,7 +122,7 @@ export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
onClose={() => {}}
/>
)}
{listMode === ListMode.Suggestions && (
{listMode === VisualizationSelectPaneTab.Suggestions && (
<VisualizationSuggestions
current={plugin.meta}
onChange={onVizChange}
@ -126,7 +132,7 @@ export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
onClose={() => {}}
/>
)}
{listMode === ListMode.LibraryPanels && (
{listMode === VisualizationSelectPaneTab.LibraryPanels && (
<PanelLibraryOptionsGroup searchQuery={searchQuery} panel={panel} key="Panel Library" />
)}
</div>
@ -136,12 +142,6 @@ export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
);
};
enum ListMode {
Visualizations,
LibraryPanels,
Suggestions,
}
VisualizationSelectPane.displayName = 'VisualizationSelectPane';
const getStyles = (theme: GrafanaTheme) => {

View File

@ -66,3 +66,9 @@ export interface OptionPaneItemOverrideInfo {
tooltip: string;
description: string;
}
export enum VisualizationSelectPaneTab {
Visualizations,
LibraryPanels,
Suggestions,
}

View File

@ -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 (
<div className={styles.wrapper}>
<div className={styles.message}>{message}</div>
{
// suggestions && (
// <div className={styles.suggestions}>
// {suggestions.map((suggestion, index) => (
// <VisualizationPreview
// key={index}
// data={data!}
// suggestion={suggestion}
// onChange={onChange}
// width={150}
// />
// ))}
// </div>
// )
}
</div>
);
}
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%;
`,
};
};

View File

@ -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 (
<div className={styles.wrapper}>
<div className={styles.message}>{message}</div>
{context.app === CoreApp.PanelEditor && dataSummary.hasData && (
<div className={styles.actions}>
<CardButton icon="table" onClick={switchToTable}>
Switch to table
</CardButton>
<CardButton icon="chart-line" onClick={openVizPicker}>
Open visualization suggestions
</CardButton>
</div>
)}
</div>
);
}
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',
}),
};
};

View File

@ -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<CandlestickOptions> {}
export const CandlestickPanel: React.FC<CandlestickPanelProps> = ({
data,
id,
timeRange,
timeZone,
width,
@ -52,6 +54,10 @@ export const CandlestickPanel: React.FC<CandlestickPanelProps> = ({
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<CandlestickPanelProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, data.structureRev]);
if (!info.frame || info.warn) {
return (
<div className="panel-empty">
<p>{info.warn ?? 'No data found in response'}</p>
</div>
);
if (!info) {
return <PanelDataErrorView panelId={id} data={data} needsTimeField={true} needsNumberField={true} />;
}
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());

View File

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

View File

@ -59,8 +59,6 @@ export const candlestickFieldsInfo: Record<keyof CandlestickFieldMap, FieldPicke
};
export interface CandlestickData {
warn?: string;
noTimeField?: boolean;
autoOpenClose?: boolean;
// Special fields
@ -97,30 +95,31 @@ export function prepareCandlestickFields(
series: DataFrame[] | undefined,
options: CandlestickOptions,
theme: GrafanaTheme2
): CandlestickData {
): CandlestickData | null {
if (!series?.length) {
return { warn: 'No data' } as CandlestickData;
return null;
}
// All fields
const fieldMap = options.fields ?? {};
const aligned = series.length === 1 ? series[0] : outerJoinDataFrames({ frames: series, enforceSort: true });
if (!aligned?.length) {
return { warn: 'No data found' } as CandlestickData;
return null;
}
const data: CandlestickData = { aligned, frame: aligned, names: {} };
// Apply same filter as everythign else in timeseries
const norm = prepareGraphableFields([aligned], theme);
if (norm.warn || norm.noTimeField || !norm.frames?.length) {
return norm as CandlestickData;
const timeSeriesFrames = prepareGraphableFields([aligned], theme);
if (!timeSeriesFrames) {
return null;
}
const frame = (data.frame = norm.frames[0]);
const frame = (data.frame = timeSeriesFrames[0]);
const timeIndex = frame.fields.findIndex((f) => f.type === FieldType.time);
if (timeIndex < 0) {
data.warn = 'Missing time field';
data.noTimeField = true;
return data;
return null;
}
// Find the known fields

View File

@ -43,18 +43,22 @@ const numericFieldFilter = (f: Field) => f.type === FieldType.number;
function addFieldPicker(
builder: PanelOptionsEditorBuilder<CandlestickOptions>,
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({

View File

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

View File

@ -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<TimeSeriesOptions> {}
@ -24,6 +25,7 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
fieldConfig,
onChangeTimeRange,
replaceVariables,
id,
}) => {
const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, onSplitOpen } = usePanelContext();
@ -31,14 +33,10 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
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 (
<div className="panel-empty">
<p>{warn ?? 'No data found in response'}</p>
</div>
);
if (!frames) {
return <PanelDataErrorView panelId={id} data={data} needsTimeField={true} needsNumberField={true} />;
}
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());

View File

@ -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,

View File

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