Graphite: Handle unknown Graphite functions without breaking the visual editor (#32635)

This commit is contained in:
Piotr Jamróz 2021-04-09 23:21:53 +02:00 committed by GitHub
parent b96e45299d
commit d8967b1d60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 162 additions and 72 deletions

View File

@ -0,0 +1,49 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { FunctionEditor } from './FunctionEditor';
import { FunctionDescriptor } from './FunctionEditorControls';
function mockFunctionDescriptor(name: string, unknown?: boolean): FunctionDescriptor {
return {
text: '',
params: [],
def: {
category: 'category',
defaultParams: [],
fake: false,
name: name,
params: [],
unknown: unknown,
},
};
}
describe('FunctionEditor', () => {
it('should display a defined function with name and no icon', () => {
render(
<FunctionEditor
func={mockFunctionDescriptor('foo')}
onMoveLeft={() => {}}
onMoveRight={() => {}}
onRemove={() => {}}
/>
);
expect(screen.getByText('foo')).toBeInTheDocument();
expect(screen.queryByTestId('warning-icon')).not.toBeInTheDocument();
});
it('should display an unknown function with name and warning icon', () => {
render(
<FunctionEditor
func={mockFunctionDescriptor('bar', true)}
onMoveLeft={jest.fn()}
onMoveRight={jest.fn()}
onRemove={jest.fn()}
/>
);
expect(screen.getByText('bar')).toBeInTheDocument();
expect(screen.getByTestId('warning-icon')).toBeInTheDocument();
});
});

View File

@ -1,82 +1,93 @@
import React from 'react'; import React, { useRef } from 'react';
import { PopoverController, Popover, ClickOutsideWrapper } from '@grafana/ui'; import { PopoverController, Popover, ClickOutsideWrapper, Icon, Tooltip, useTheme } from '@grafana/ui';
import { FunctionDescriptor, FunctionEditorControls, FunctionEditorControlsProps } from './FunctionEditorControls'; import { FunctionDescriptor, FunctionEditorControls, FunctionEditorControlsProps } from './FunctionEditorControls';
import { css } from '@emotion/css';
interface FunctionEditorProps extends FunctionEditorControlsProps { interface FunctionEditorProps extends FunctionEditorControlsProps {
func: FunctionDescriptor; func: FunctionDescriptor;
} }
interface FunctionEditorState { const FunctionEditor: React.FC<FunctionEditorProps> = ({ onMoveLeft, onMoveRight, func, ...props }) => {
showingDescription: boolean; const triggerRef = useRef<HTMLSpanElement>(null);
} const theme = useTheme();
class FunctionEditor extends React.PureComponent<FunctionEditorProps, FunctionEditorState> { const renderContent = ({ updatePopperPosition }: any) => (
private triggerRef = React.createRef<HTMLSpanElement>(); <FunctionEditorControls
{...props}
func={func}
onMoveLeft={() => {
onMoveLeft(func);
updatePopperPosition();
}}
onMoveRight={() => {
onMoveRight(func);
updatePopperPosition();
}}
/>
);
constructor(props: FunctionEditorProps) { return (
super(props); <PopoverController content={renderContent} placement="top" hideAfter={100}>
{(showPopper, hidePopper, popperProps) => {
return (
<>
{triggerRef.current && (
<Popover
{...popperProps}
referenceElement={triggerRef.current}
wrapperClassName="popper"
className="popper__background"
renderArrow={({ arrowProps, placement }) => (
<div className="popper__arrow" data-placement={placement} {...arrowProps} />
)}
/>
)}
<ClickOutsideWrapper
onClick={() => {
if (popperProps.show) {
hidePopper();
}
}}
>
<span ref={triggerRef} onClick={popperProps.show ? hidePopper : showPopper} style={{ cursor: 'pointer' }}>
{func.def.unknown && (
<Tooltip content={<TooltipContent />} placement="bottom">
<Icon
data-testid="warning-icon"
name="exclamation-triangle"
size="xs"
className={css`
margin-right: ${theme.spacing.xxs};
`}
/>
</Tooltip>
)}
{func.def.name}
</span>
</ClickOutsideWrapper>
</>
);
}}
</PopoverController>
);
};
this.state = { const TooltipContent = React.memo(() => {
showingDescription: false, return (
}; <span>
} This function is not supported. Check your function for typos and{' '}
<a
renderContent = ({ updatePopperPosition }: any) => { target="_blank"
const { onMoveLeft, onMoveRight } = this.props; className="external-link"
rel="noreferrer noopener"
return ( href="https://graphite.readthedocs.io/en/latest/functions.html"
<FunctionEditorControls >
{...this.props} read the docs
onMoveLeft={() => { </a>{' '}
onMoveLeft(this.props.func); to see whether you need to upgrade your data sources version to make this function available.
updatePopperPosition(); </span>
}} );
onMoveRight={() => { });
onMoveRight(this.props.func); TooltipContent.displayName = 'FunctionEditorTooltipContent';
updatePopperPosition();
}}
/>
);
};
render() {
return (
<PopoverController content={this.renderContent} placement="top" hideAfter={100}>
{(showPopper, hidePopper, popperProps) => {
return (
<>
{this.triggerRef.current && (
<Popover
{...popperProps}
referenceElement={this.triggerRef.current}
wrapperClassName="popper"
className="popper__background"
renderArrow={({ arrowProps, placement }) => (
<div className="popper__arrow" data-placement={placement} {...arrowProps} />
)}
/>
)}
<ClickOutsideWrapper
onClick={() => {
if (popperProps.show) {
hidePopper();
}
}}
>
<span
ref={this.triggerRef}
onClick={popperProps.show ? hidePopper : showPopper}
style={{ cursor: 'pointer' }}
>
{this.props.func.def.name}
</span>
</ClickOutsideWrapper>
</>
);
}}
</PopoverController>
);
}
}
export { FunctionEditor }; export { FunctionEditor };

View File

@ -11,6 +11,10 @@ export interface FunctionDescriptor {
fake: boolean; fake: boolean;
name: string; name: string;
params: string[]; params: string[];
/**
* True if the function was not found on the list of available function descriptions.
*/
unknown?: boolean;
}; };
} }

View File

@ -166,6 +166,10 @@ export function graphiteFuncEditor($compile: any, templateSrv: TemplateSrv) {
function addElementsAndCompile() { function addElementsAndCompile() {
$funcLink.appendTo(elem); $funcLink.appendTo(elem);
if (func.def.unknown) {
elem.addClass('unknown-function');
}
const defParams: any = _.clone(func.def.params); const defParams: any = _.clone(func.def.params);
const lastParam: any = _.last(func.def.params); const lastParam: any = _.last(func.def.params);

View File

@ -0,0 +1,18 @@
import gfunc from './gfunc';
describe('gfunc', () => {
const INDEX = {
foo: {
name: 'foo',
params: [],
},
};
it('returns function from the index', () => {
expect(gfunc.getFuncDef('foo', INDEX)).toEqual(INDEX.foo);
});
it('marks function as unknown when it is not available in the index', () => {
expect(gfunc.getFuncDef('bar', INDEX)).toEqual({ name: 'bar', params: [{ multiple: true }], unknown: true });
});
});

View File

@ -1083,7 +1083,7 @@ function createFuncInstance(funcDef: any, options?: { withDefaultParams: any },
function getFuncDef(name: string, idx?: any) { function getFuncDef(name: string, idx?: any) {
if (!(idx || index)[name]) { if (!(idx || index)[name]) {
throw { message: 'Method not found ' + name }; return { name: name, params: [{ multiple: true }], unknown: true };
} }
return (idx || index)[name]; return (idx || index)[name];
} }

View File

@ -1,6 +1,10 @@
.query-part { .query-part {
background-color: $tight-form-func-bg; background-color: $tight-form-func-bg;
&.unknown-function {
border: 1px solid $error-text-color;
}
&.show-function-controls { &.show-function-controls {
padding-top: 5px; padding-top: 5px;
min-width: 100px; min-width: 100px;