* Add UMLs
* Add rendered diagrams
* Move QueryCtrl to flux
* Remove redundant param in the reducer
* Use named imports for lodash and fix typing for GraphiteTagOperator
* Add missing async/await
* Extract providers to a separate file
* Clean up async await
* Rename controller functions back to main
* Simplify creating actions
* Re-order controller functions
* Separate helpers from actions
* Rename vars
* Simplify helpers
* Move controller methods to state reducers
* Remove docs (they are added in design doc)
* Move actions.ts to state folder
* Add docs
* Add old methods stubs for easier review
* Check how state dependencies will be mapped
* Rename state to store
* Rename state to store
* Rewrite spec tests for Graphite Query Controller
* Update docs
* Update docs
* Add GraphiteTextEditor
* Add play button
* Add AddGraphiteFunction
* Use Segment to simplify AddGraphiteFunction
* Memoize function defs
* Fix useCallback deps
* Update public/app/plugins/datasource/graphite/state/helpers.ts
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
* Update public/app/plugins/datasource/graphite/state/helpers.ts
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
* Update public/app/plugins/datasource/graphite/state/helpers.ts
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
* Update public/app/plugins/datasource/graphite/state/providers.ts
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
* Update public/app/plugins/datasource/graphite/state/providers.ts
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
* Update public/app/plugins/datasource/graphite/state/providers.ts
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
* Update public/app/plugins/datasource/graphite/state/providers.ts
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
* Update public/app/plugins/datasource/graphite/state/providers.ts
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
* Update public/app/plugins/datasource/graphite/state/providers.ts
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
* Add more type definitions
* Remove submitOnClickAwayOption
This behavior is actually needed to remove parameters in functions
* Load function definitions before parsing the target on initial load
* Add button padding
* Fix loading function definitions
* Change targetChanged to updateQuery to avoid mutating state directly
It's also needed for extra refresh/runQuery execution as handleTargetChanged doesn't handle changing the raw query
* Fix updating query after adding a function
* Simplify updating function params
* Migrate function editor to react
* Simplify setting Segment Select min width
* Remove unnecessary changes to SegmentInput
* Extract view logic to a helper and update types definitions
* Clean up types
* Update FuncDef types and add tests
* Show red border for unknown functions
* Autofocus on new params
* Extract params mapping to a helper
* Split code between params and function editor
* Focus on the first param when a function is added even if it's an optional argument
* Add function editor tests
* Remove todo marker
* Fix adding new functions
* Allow empty value in selects for removing function params
* Add placeholders and fix styling
* Add more docs
* Rename .tsx -> .ts
* Update types
* Use ?? instead of || + add test for mapping options
* Use const (let is not needed here)
* Revert test name change
* Allow removing only optional params and mark additional params as optional (only the first one is required)
* Use theme.typography.bodySmall.fontSize
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
(cherry picked from commit 8d7e22e1bb)
Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
This commit is contained in:
co-authored by
Piotr Jamróz
parent
3c4754b19f
commit
c2d807a1c8
@@ -11,6 +11,7 @@ export interface SegmentSyncProps<T> extends SegmentProps<T>, Omit<HTMLProps<HTM
|
|||||||
value?: T | SelectableValue<T>;
|
value?: T | SelectableValue<T>;
|
||||||
onChange: (item: SelectableValue<T>) => void;
|
onChange: (item: SelectableValue<T>) => void;
|
||||||
options: Array<SelectableValue<T>>;
|
options: Array<SelectableValue<T>>;
|
||||||
|
inputMinWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Segment<T>({
|
export function Segment<T>({
|
||||||
@@ -20,12 +21,16 @@ export function Segment<T>({
|
|||||||
Component,
|
Component,
|
||||||
className,
|
className,
|
||||||
allowCustomValue,
|
allowCustomValue,
|
||||||
|
allowEmptyValue,
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled,
|
disabled,
|
||||||
inputMinWidth,
|
inputMinWidth,
|
||||||
|
inputPlaceholder,
|
||||||
|
onExpandedChange,
|
||||||
|
autofocus = false,
|
||||||
...rest
|
...rest
|
||||||
}: React.PropsWithChildren<SegmentSyncProps<T>>) {
|
}: React.PropsWithChildren<SegmentSyncProps<T>>) {
|
||||||
const [Label, labelWidth, expanded, setExpanded] = useExpandableLabel(false);
|
const [Label, labelWidth, expanded, setExpanded] = useExpandableLabel(autofocus, onExpandedChange);
|
||||||
const width = inputMinWidth ? Math.max(inputMinWidth, labelWidth) : labelWidth;
|
const width = inputMinWidth ? Math.max(inputMinWidth, labelWidth) : labelWidth;
|
||||||
const styles = useStyles(getSegmentStyles);
|
const styles = useStyles(getSegmentStyles);
|
||||||
|
|
||||||
@@ -59,10 +64,12 @@ export function Segment<T>({
|
|||||||
<SegmentSelect
|
<SegmentSelect
|
||||||
{...rest}
|
{...rest}
|
||||||
value={value && !isObject(value) ? { value } : value}
|
value={value && !isObject(value) ? { value } : value}
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
options={options}
|
options={options}
|
||||||
width={width}
|
width={width}
|
||||||
onClickOutside={() => setExpanded(false)}
|
onClickOutside={() => setExpanded(false)}
|
||||||
allowCustomValue={allowCustomValue}
|
allowCustomValue={allowCustomValue}
|
||||||
|
allowEmptyValue={allowEmptyValue}
|
||||||
onChange={(item) => {
|
onChange={(item) => {
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
onChange(item);
|
onChange(item);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface SegmentAsyncProps<T> extends SegmentProps<T>, Omit<HTMLProps<HT
|
|||||||
loadOptions: (query?: string) => Promise<Array<SelectableValue<T>>>;
|
loadOptions: (query?: string) => Promise<Array<SelectableValue<T>>>;
|
||||||
onChange: (item: SelectableValue<T>) => void;
|
onChange: (item: SelectableValue<T>) => void;
|
||||||
noOptionMessageHandler?: (state: AsyncState<Array<SelectableValue<T>>>) => string;
|
noOptionMessageHandler?: (state: AsyncState<Array<SelectableValue<T>>>) => string;
|
||||||
|
inputMinWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SegmentAsync<T>({
|
export function SegmentAsync<T>({
|
||||||
@@ -24,14 +25,18 @@ export function SegmentAsync<T>({
|
|||||||
Component,
|
Component,
|
||||||
className,
|
className,
|
||||||
allowCustomValue,
|
allowCustomValue,
|
||||||
|
allowEmptyValue,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
inputMinWidth,
|
inputMinWidth,
|
||||||
|
inputPlaceholder,
|
||||||
|
autofocus = false,
|
||||||
|
onExpandedChange,
|
||||||
noOptionMessageHandler = mapStateToNoOptionsMessage,
|
noOptionMessageHandler = mapStateToNoOptionsMessage,
|
||||||
...rest
|
...rest
|
||||||
}: React.PropsWithChildren<SegmentAsyncProps<T>>) {
|
}: React.PropsWithChildren<SegmentAsyncProps<T>>) {
|
||||||
const [state, fetchOptions] = useAsyncFn(loadOptions, [loadOptions]);
|
const [state, fetchOptions] = useAsyncFn(loadOptions, [loadOptions]);
|
||||||
const [Label, labelWidth, expanded, setExpanded] = useExpandableLabel(false);
|
const [Label, labelWidth, expanded, setExpanded] = useExpandableLabel(autofocus, onExpandedChange);
|
||||||
const width = inputMinWidth ? Math.max(inputMinWidth, labelWidth) : labelWidth;
|
const width = inputMinWidth ? Math.max(inputMinWidth, labelWidth) : labelWidth;
|
||||||
const styles = useStyles(getSegmentStyles);
|
const styles = useStyles(getSegmentStyles);
|
||||||
|
|
||||||
@@ -66,10 +71,12 @@ export function SegmentAsync<T>({
|
|||||||
<SegmentSelect
|
<SegmentSelect
|
||||||
{...rest}
|
{...rest}
|
||||||
value={value && !isObject(value) ? { value } : value}
|
value={value && !isObject(value) ? { value } : value}
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
options={state.value ?? []}
|
options={state.value ?? []}
|
||||||
width={width}
|
width={width}
|
||||||
noOptionsMessage={noOptionMessageHandler(state)}
|
noOptionsMessage={noOptionMessageHandler(state)}
|
||||||
allowCustomValue={allowCustomValue}
|
allowCustomValue={allowCustomValue}
|
||||||
|
allowEmptyValue={allowEmptyValue}
|
||||||
onClickOutside={() => {
|
onClickOutside={() => {
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useStyles } from '../../themes';
|
|||||||
export interface SegmentInputProps<T> extends SegmentProps<T>, Omit<HTMLProps<HTMLInputElement>, 'value' | 'onChange'> {
|
export interface SegmentInputProps<T> extends SegmentProps<T>, Omit<HTMLProps<HTMLInputElement>, 'value' | 'onChange'> {
|
||||||
value: string | number;
|
value: string | number;
|
||||||
onChange: (text: string | number) => void;
|
onChange: (text: string | number) => void;
|
||||||
autofocus?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FONT_SIZE = 14;
|
const FONT_SIZE = 14;
|
||||||
@@ -21,14 +20,16 @@ export function SegmentInput<T>({
|
|||||||
Component,
|
Component,
|
||||||
className,
|
className,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
inputPlaceholder,
|
||||||
disabled,
|
disabled,
|
||||||
autofocus = false,
|
autofocus = false,
|
||||||
|
onExpandedChange,
|
||||||
...rest
|
...rest
|
||||||
}: React.PropsWithChildren<SegmentInputProps<T>>) {
|
}: React.PropsWithChildren<SegmentInputProps<T>>) {
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
const [value, setValue] = useState<number | string>(initialValue);
|
const [value, setValue] = useState<number | string>(initialValue);
|
||||||
const [inputWidth, setInputWidth] = useState<number>(measureText((initialValue || '').toString(), FONT_SIZE).width);
|
const [inputWidth, setInputWidth] = useState<number>(measureText((initialValue || '').toString(), FONT_SIZE).width);
|
||||||
const [Label, , expanded, setExpanded] = useExpandableLabel(autofocus);
|
const [Label, , expanded, setExpanded] = useExpandableLabel(autofocus, onExpandedChange);
|
||||||
const styles = useStyles(getSegmentStyles);
|
const styles = useStyles(getSegmentStyles);
|
||||||
|
|
||||||
useClickAway(ref, () => {
|
useClickAway(ref, () => {
|
||||||
@@ -71,6 +72,7 @@ export function SegmentInput<T>({
|
|||||||
autoFocus
|
autoFocus
|
||||||
className={cx(`gf-form gf-form-input`, inputWidthStyle)}
|
className={cx(`gf-form gf-form-input`, inputWidthStyle)}
|
||||||
value={value}
|
value={value}
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
onChange={(item) => {
|
onChange={(item) => {
|
||||||
const { width } = measureText(item.target.value, FONT_SIZE);
|
const { width } = measureText(item.target.value, FONT_SIZE);
|
||||||
setInputWidth(width);
|
setInputWidth(width);
|
||||||
|
|||||||
@@ -15,17 +15,25 @@ export interface Props<T> extends Omit<HTMLProps<HTMLDivElement>, 'value' | 'onC
|
|||||||
width: number;
|
width: number;
|
||||||
noOptionsMessage?: string;
|
noOptionsMessage?: string;
|
||||||
allowCustomValue?: boolean;
|
allowCustomValue?: boolean;
|
||||||
|
/**
|
||||||
|
* If true, empty value will be passed to onChange callback otherwise using empty value
|
||||||
|
* will work as canceling and using the previous value
|
||||||
|
*/
|
||||||
|
allowEmptyValue?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export function SegmentSelect<T>({
|
export function SegmentSelect<T>({
|
||||||
value,
|
value,
|
||||||
|
placeholder = '',
|
||||||
options = [],
|
options = [],
|
||||||
onChange,
|
onChange,
|
||||||
onClickOutside,
|
onClickOutside,
|
||||||
width: widthPixels,
|
width: widthPixels,
|
||||||
noOptionsMessage = '',
|
noOptionsMessage = '',
|
||||||
allowCustomValue = false,
|
allowCustomValue = false,
|
||||||
|
allowEmptyValue = false,
|
||||||
...rest
|
...rest
|
||||||
}: React.PropsWithChildren<Props<T>>) {
|
}: React.PropsWithChildren<Props<T>>) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -38,7 +46,7 @@ export function SegmentSelect<T>({
|
|||||||
<Select
|
<Select
|
||||||
width={width}
|
width={width}
|
||||||
noOptionsMessage={noOptionsMessage}
|
noOptionsMessage={noOptionsMessage}
|
||||||
placeholder=""
|
placeholder={placeholder}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -53,7 +61,7 @@ export function SegmentSelect<T>({
|
|||||||
// https://github.com/JedWatson/react-select/issues/188#issuecomment-279240292
|
// https://github.com/JedWatson/react-select/issues/188#issuecomment-279240292
|
||||||
// Unfortunately there's no other way of retrieving the value (not yet) created new option
|
// Unfortunately there's no other way of retrieving the value (not yet) created new option
|
||||||
const input = ref.current.querySelector('input[id^="react-select-"]') as HTMLInputElement;
|
const input = ref.current.querySelector('input[id^="react-select-"]') as HTMLInputElement;
|
||||||
if (input && input.value) {
|
if (input && (input.value || allowEmptyValue)) {
|
||||||
onChange({ value: input.value as any, label: input.value });
|
onChange({ value: input.value as any, label: input.value });
|
||||||
} else {
|
} else {
|
||||||
onClickOutside();
|
onClickOutside();
|
||||||
|
|||||||
@@ -6,5 +6,8 @@ export interface SegmentProps<T> {
|
|||||||
allowCustomValue?: boolean;
|
allowCustomValue?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
inputMinWidth?: number;
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
|
autofocus?: boolean;
|
||||||
|
allowEmptyValue?: boolean;
|
||||||
|
inputPlaceholder?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,20 @@ interface LabelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useExpandableLabel = (
|
export const useExpandableLabel = (
|
||||||
initialExpanded: boolean
|
initialExpanded: boolean,
|
||||||
|
onExpandedChange?: (expanded: boolean) => void
|
||||||
): [React.ComponentType<LabelProps>, number, boolean, (expanded: boolean) => void] => {
|
): [React.ComponentType<LabelProps>, number, boolean, (expanded: boolean) => void] => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [expanded, setExpanded] = useState<boolean>(initialExpanded);
|
const [expanded, setExpanded] = useState<boolean>(initialExpanded);
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
|
|
||||||
|
const setExpandedWrapper = (expanded: boolean) => {
|
||||||
|
setExpanded(expanded);
|
||||||
|
if (onExpandedChange) {
|
||||||
|
onExpandedChange(expanded);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const Label: React.FC<LabelProps> = ({ Component, onClick, disabled }) => (
|
const Label: React.FC<LabelProps> = ({ Component, onClick, disabled }) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -20,7 +28,7 @@ export const useExpandableLabel = (
|
|||||||
disabled
|
disabled
|
||||||
? undefined
|
? undefined
|
||||||
: () => {
|
: () => {
|
||||||
setExpanded(true);
|
setExpandedWrapper(true);
|
||||||
if (ref && ref.current) {
|
if (ref && ref.current) {
|
||||||
setWidth(ref.current.clientWidth * 1.25);
|
setWidth(ref.current.clientWidth * 1.25);
|
||||||
}
|
}
|
||||||
@@ -34,5 +42,5 @@ export const useExpandableLabel = (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return [Label, width, expanded, setExpanded];
|
return [Label, width, expanded, setExpandedWrapper];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import QueryEditor from 'app/plugins/datasource/grafana-azure-monitor-datasource
|
|||||||
import { GraphiteTextEditor } from '../plugins/datasource/graphite/components/GraphiteTextEditor';
|
import { GraphiteTextEditor } from '../plugins/datasource/graphite/components/GraphiteTextEditor';
|
||||||
import { PlayButton } from '../plugins/datasource/graphite/components/PlayButton';
|
import { PlayButton } from '../plugins/datasource/graphite/components/PlayButton';
|
||||||
import { AddGraphiteFunction } from '../plugins/datasource/graphite/components/AddGraphiteFunction';
|
import { AddGraphiteFunction } from '../plugins/datasource/graphite/components/AddGraphiteFunction';
|
||||||
|
import { GraphiteFunctionEditor } from '../plugins/datasource/graphite/components/GraphiteFunctionEditor';
|
||||||
|
|
||||||
const { SecretFormField } = LegacyForms;
|
const { SecretFormField } = LegacyForms;
|
||||||
|
|
||||||
@@ -209,4 +210,5 @@ export function registerAngularDirectives() {
|
|||||||
react2AngularDirective('graphiteTextEditor', GraphiteTextEditor, ['rawQuery', 'dispatch']);
|
react2AngularDirective('graphiteTextEditor', GraphiteTextEditor, ['rawQuery', 'dispatch']);
|
||||||
react2AngularDirective('playButton', PlayButton, ['dispatch']);
|
react2AngularDirective('playButton', PlayButton, ['dispatch']);
|
||||||
react2AngularDirective('addGraphiteFunction', AddGraphiteFunction, ['funcDefs', 'dispatch']);
|
react2AngularDirective('addGraphiteFunction', AddGraphiteFunction, ['funcDefs', 'dispatch']);
|
||||||
|
react2AngularDirective('graphiteFunctionEditor', GraphiteFunctionEditor, ['func', 'dispatch']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { PopoverController, Popover, ClickOutsideWrapper, Icon, Tooltip, useTheme } from '@grafana/ui';
|
import { PopoverController, Popover, ClickOutsideWrapper, Icon, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
import { FunctionEditorControls, FunctionEditorControlsProps } from './FunctionEditorControls';
|
import { FunctionEditorControls, FunctionEditorControlsProps } from './FunctionEditorControls';
|
||||||
import { FuncInstance } from './gfunc';
|
import { FuncInstance } from './gfunc';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
interface FunctionEditorProps extends FunctionEditorControlsProps {
|
interface FunctionEditorProps extends FunctionEditorControlsProps {
|
||||||
func: FuncInstance;
|
func: FuncInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
icon: css`
|
||||||
|
margin-right: ${theme.spacing(0.5)};
|
||||||
|
`,
|
||||||
|
label: css({
|
||||||
|
fontWeight: theme.typography.fontWeightMedium,
|
||||||
|
fontSize: theme.typography.bodySmall.fontSize, // to match .gf-form-label
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'inline-block',
|
||||||
|
paddingBottom: '2px',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const FunctionEditor: React.FC<FunctionEditorProps> = ({ onMoveLeft, onMoveRight, func, ...props }) => {
|
const FunctionEditor: React.FC<FunctionEditorProps> = ({ onMoveLeft, onMoveRight, func, ...props }) => {
|
||||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||||
const theme = useTheme();
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const renderContent = ({ updatePopperPosition }: any) => (
|
const renderContent = ({ updatePopperPosition }: any) => (
|
||||||
<FunctionEditorControls
|
<FunctionEditorControls
|
||||||
@@ -50,17 +66,10 @@ const FunctionEditor: React.FC<FunctionEditorProps> = ({ onMoveLeft, onMoveRight
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span ref={triggerRef} onClick={popperProps.show ? hidePopper : showPopper} style={{ cursor: 'pointer' }}>
|
<span ref={triggerRef} onClick={popperProps.show ? hidePopper : showPopper} className={styles.label}>
|
||||||
{func.def.unknown && (
|
{func.def.unknown && (
|
||||||
<Tooltip content={<TooltipContent />} placement="bottom">
|
<Tooltip content={<TooltipContent />} placement="bottom">
|
||||||
<Icon
|
<Icon data-testid="warning-icon" name="exclamation-triangle" size="xs" className={styles.icon} />
|
||||||
data-testid="warning-icon"
|
|
||||||
name="exclamation-triangle"
|
|
||||||
size="xs"
|
|
||||||
className={css`
|
|
||||||
margin-right: ${theme.spacing.xxs};
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{func.def.name}
|
{func.def.name}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Button, Segment, useStyles2 } from '@grafana/ui';
|
import { Button, Segment, useStyles2 } from '@grafana/ui';
|
||||||
import { FuncDefs } from '../gfunc';
|
import { FuncDefs } from '../gfunc';
|
||||||
import { actions } from '../state/actions';
|
import { actions } from '../state/actions';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { mapFuncDefsToSelectables } from './helpers';
|
import { mapFuncDefsToSelectables } from './helpers';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
@@ -13,21 +13,29 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AddGraphiteFunction({ dispatch, funcDefs }: Props) {
|
export function AddGraphiteFunction({ dispatch, funcDefs }: Props) {
|
||||||
const onChange = useCallback(
|
const [value, setValue] = useState<SelectableValue<string> | undefined>(undefined);
|
||||||
({ value }) => {
|
|
||||||
dispatch(actions.addFunction({ name: value }));
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const options = useMemo(() => mapFuncDefsToSelectables(funcDefs), [funcDefs]);
|
const options = useMemo(() => mapFuncDefsToSelectables(funcDefs), [funcDefs]);
|
||||||
|
|
||||||
|
// Note: actions.addFunction will add a component that will have a dropdown or input in auto-focus
|
||||||
|
// (the first param of the function). This auto-focus will cause onBlur() on AddGraphiteFunction's
|
||||||
|
// Segment component and trigger onChange once again. (why? we call onChange if the user dismissed
|
||||||
|
// the dropdown, see: SegmentSelect.onCloseMenu for more details). To avoid it we need to wait for
|
||||||
|
// the Segment to disappear first (hence useEffect) and then dispatch the action that will add new
|
||||||
|
// components.
|
||||||
|
useEffect(() => {
|
||||||
|
if (value?.value !== undefined) {
|
||||||
|
dispatch(actions.addFunction({ name: value.value }));
|
||||||
|
setValue(undefined);
|
||||||
|
}
|
||||||
|
}, [value, dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Segment
|
<Segment
|
||||||
Component={<Button icon="plus" variant="secondary" className={cx(styles.button)} />}
|
Component={<Button icon="plus" variant="secondary" className={cx(styles.button)} />}
|
||||||
options={options}
|
options={options}
|
||||||
onChange={onChange}
|
onChange={setValue}
|
||||||
inputMinWidth={150}
|
inputMinWidth={150}
|
||||||
></Segment>
|
></Segment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Segment, SegmentInput, useStyles2 } from '@grafana/ui';
|
||||||
|
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
|
||||||
|
export type EditableParam = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
optional: boolean;
|
||||||
|
multiple: boolean;
|
||||||
|
options: Array<SelectableValue<string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FieldEditorProps = {
|
||||||
|
editableParam: EditableParam;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onExpandedChange: (expanded: boolean) => void;
|
||||||
|
autofocus: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a function parameter with a segment dropdown for multiple options or simple input.
|
||||||
|
*/
|
||||||
|
export function FunctionParamEditor({ editableParam, onChange, onExpandedChange, autofocus }: FieldEditorProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
if (editableParam.options?.length > 0) {
|
||||||
|
return (
|
||||||
|
<Segment
|
||||||
|
autofocus={autofocus}
|
||||||
|
value={editableParam.value}
|
||||||
|
inputPlaceholder={editableParam.name}
|
||||||
|
className={styles.segment}
|
||||||
|
options={editableParam.options}
|
||||||
|
placeholder={' +' + editableParam.name}
|
||||||
|
onChange={(value) => {
|
||||||
|
onChange(value.value || '');
|
||||||
|
}}
|
||||||
|
onExpandedChange={onExpandedChange}
|
||||||
|
inputMinWidth={150}
|
||||||
|
allowCustomValue={true}
|
||||||
|
allowEmptyValue={true}
|
||||||
|
></Segment>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<SegmentInput
|
||||||
|
autofocus={autofocus}
|
||||||
|
className={styles.input}
|
||||||
|
value={editableParam.value || ''}
|
||||||
|
placeholder={' +' + editableParam.name}
|
||||||
|
inputPlaceholder={editableParam.name}
|
||||||
|
onChange={(value) => {
|
||||||
|
onChange(value.toString());
|
||||||
|
}}
|
||||||
|
onExpandedChange={onExpandedChange}
|
||||||
|
// input style
|
||||||
|
style={{ height: '25px', paddingTop: '2px', marginTop: '2px', paddingLeft: '4px', minWidth: '100px' }}
|
||||||
|
></SegmentInput>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
segment: css({
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
}),
|
||||||
|
input: css`
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
input {
|
||||||
|
height: 25px;
|
||||||
|
},
|
||||||
|
`,
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { HorizontalGroup, InlineLabel, useStyles2 } from '@grafana/ui';
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { css, cx } from '@emotion/css';
|
||||||
|
import { FuncInstance } from '../gfunc';
|
||||||
|
import { EditableParam, FunctionParamEditor } from './FunctionParamEditor';
|
||||||
|
import { actions } from '../state/actions';
|
||||||
|
import { FunctionEditor } from '../FunctionEditor';
|
||||||
|
import { mapFuncInstanceToParams } from './helpers';
|
||||||
|
|
||||||
|
export type FunctionEditorProps = {
|
||||||
|
func: FuncInstance;
|
||||||
|
dispatch: (action: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows editing function params and removing/moving a function (note: editing function name is not supported)
|
||||||
|
*/
|
||||||
|
export function GraphiteFunctionEditor({ func, dispatch }: FunctionEditorProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
// keep track of mouse over and isExpanded state to display buttons for adding optional/multiple params
|
||||||
|
// only when the user mouse over over the function editor OR any param editor is expanded.
|
||||||
|
const [mouseOver, setIsMouseOver] = useState(false);
|
||||||
|
const [expanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
let params = mapFuncInstanceToParams(func);
|
||||||
|
params = params.filter((p: EditableParam, index: number) => {
|
||||||
|
// func.added is set for newly added functions - see autofocus below
|
||||||
|
return (index < func.def.params.length && !p.optional) || func.added || p.value || expanded || mouseOver;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(styles.container, { [styles.error]: func.def.unknown })}
|
||||||
|
onMouseOver={() => setIsMouseOver(true)}
|
||||||
|
onMouseLeave={() => setIsMouseOver(false)}
|
||||||
|
>
|
||||||
|
<HorizontalGroup spacing="none">
|
||||||
|
<FunctionEditor
|
||||||
|
func={func}
|
||||||
|
onMoveLeft={() => {
|
||||||
|
dispatch(actions.moveFunction({ func, offset: -1 }));
|
||||||
|
}}
|
||||||
|
onMoveRight={() => {
|
||||||
|
dispatch(actions.moveFunction({ func, offset: 1 }));
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
dispatch(actions.removeFunction({ func }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InlineLabel className={styles.label}>(</InlineLabel>
|
||||||
|
{params.map((editableParam: EditableParam, index: number) => {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<FunctionParamEditor
|
||||||
|
autofocus={index === 0 && func.added}
|
||||||
|
editableParam={editableParam}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value !== '' || editableParam.optional) {
|
||||||
|
dispatch(actions.updateFunctionParam({ func, index, value }));
|
||||||
|
}
|
||||||
|
setIsExpanded(false);
|
||||||
|
setIsMouseOver(false);
|
||||||
|
}}
|
||||||
|
onExpandedChange={setIsExpanded}
|
||||||
|
/>
|
||||||
|
{index !== params.length - 1 ? ',' : ''}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<InlineLabel className={styles.label}>)</InlineLabel>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
container: css({
|
||||||
|
backgroundColor: theme.colors.background.secondary,
|
||||||
|
borderRadius: theme.shape.borderRadius(),
|
||||||
|
marginRight: theme.spacing(0.5),
|
||||||
|
padding: `0 ${theme.spacing(1)}`,
|
||||||
|
}),
|
||||||
|
error: css`
|
||||||
|
border: 1px solid ${theme.colors.error.main};
|
||||||
|
`,
|
||||||
|
label: css({
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
}),
|
||||||
|
button: css({
|
||||||
|
padding: theme.spacing(0.5),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { mapFuncDefsToSelectables, mapFuncInstanceToParams } from './helpers';
|
||||||
|
import { FuncDef, FuncDefs, FuncInstance } from '../gfunc';
|
||||||
|
import { EditableParam } from './FunctionParamEditor';
|
||||||
|
|
||||||
|
function createFunctionInstance(funcDef: FuncDef, currentParams: string[]): FuncInstance {
|
||||||
|
let funcInstance: FuncInstance = new FuncInstance(funcDef);
|
||||||
|
funcInstance.params = currentParams;
|
||||||
|
return funcInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Graphite components helpers', () => {
|
||||||
|
it('converts function definitions to selectable options', function () {
|
||||||
|
const functionDefs: FuncDefs = {
|
||||||
|
functionA1: { name: 'functionA1', category: 'A', params: [], defaultParams: [] },
|
||||||
|
functionB1: { name: 'functionB1', category: 'B', params: [], defaultParams: [] },
|
||||||
|
functionA2: { name: 'functionA2', category: 'A', params: [], defaultParams: [] },
|
||||||
|
functionB2: { name: 'functionB2', category: 'B', params: [], defaultParams: [] },
|
||||||
|
};
|
||||||
|
const options = mapFuncDefsToSelectables(functionDefs);
|
||||||
|
|
||||||
|
expect(options).toMatchObject([
|
||||||
|
{
|
||||||
|
label: 'A',
|
||||||
|
options: [
|
||||||
|
{ label: 'functionA1', value: 'functionA1' },
|
||||||
|
{ label: 'functionA2', value: 'functionA2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'B',
|
||||||
|
options: [
|
||||||
|
{ label: 'functionB1', value: 'functionB1' },
|
||||||
|
{ label: 'functionB2', value: 'functionB2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapFuncInstanceToParams', () => {
|
||||||
|
let funcDef: FuncDef;
|
||||||
|
|
||||||
|
function assertFunctionInstance(definition: FuncDef, params: string[], expected: EditableParam[]): void {
|
||||||
|
expect(mapFuncInstanceToParams(createFunctionInstance(definition, params))).toMatchObject(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('converts param options to selectable options', () => {
|
||||||
|
const funcDef = {
|
||||||
|
name: 'functionA1',
|
||||||
|
category: 'A',
|
||||||
|
params: [{ name: 'foo', type: 'any', optional: false, multiple: false, options: ['foo', 2] }],
|
||||||
|
defaultParams: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
assertFunctionInstance(
|
||||||
|
funcDef,
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'foo',
|
||||||
|
multiple: false,
|
||||||
|
optional: false,
|
||||||
|
options: [
|
||||||
|
{ label: 'foo', value: 'foo' },
|
||||||
|
{ label: '2', value: '2' },
|
||||||
|
],
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when all parameters are required and no multiple values are allowed', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
funcDef = {
|
||||||
|
name: 'allRequiredNoMultiple',
|
||||||
|
category: 'A',
|
||||||
|
params: [
|
||||||
|
{ name: 'a', type: 'any', optional: false, multiple: false },
|
||||||
|
{ name: 'b', type: 'any', optional: false, multiple: false },
|
||||||
|
],
|
||||||
|
defaultParams: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates required params', () => {
|
||||||
|
assertFunctionInstance(
|
||||||
|
funcDef,
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{ name: 'a', multiple: false, optional: false, options: [], value: '' },
|
||||||
|
{ name: 'b', multiple: false, optional: false, options: [], value: '' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills in provided parameters', () => {
|
||||||
|
assertFunctionInstance(
|
||||||
|
funcDef,
|
||||||
|
['a', 'b'],
|
||||||
|
[
|
||||||
|
{ name: 'a', multiple: false, optional: false, options: [], value: 'a' },
|
||||||
|
{ name: 'b', multiple: false, optional: false, options: [], value: 'b' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when all parameters are required and multiple values are allowed', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
funcDef = {
|
||||||
|
name: 'allRequiredWithMultiple',
|
||||||
|
category: 'A',
|
||||||
|
params: [
|
||||||
|
{ name: 'a', type: 'any', optional: false, multiple: false },
|
||||||
|
{ name: 'b', type: 'any', optional: false, multiple: true },
|
||||||
|
],
|
||||||
|
defaultParams: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add extra param to add multiple values if not all params are filled in', () => {
|
||||||
|
assertFunctionInstance(
|
||||||
|
funcDef,
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{ name: 'a', multiple: false, optional: false, options: [], value: '' },
|
||||||
|
{ name: 'b', multiple: true, optional: false, options: [], value: '' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
assertFunctionInstance(
|
||||||
|
funcDef,
|
||||||
|
['a'],
|
||||||
|
[
|
||||||
|
{ name: 'a', multiple: false, optional: false, options: [], value: 'a' },
|
||||||
|
{ name: 'b', multiple: true, optional: false, options: [], value: '' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks additional params as optional (only first one is required)', () => {
|
||||||
|
assertFunctionInstance(
|
||||||
|
funcDef,
|
||||||
|
['a', 'b', 'b2'],
|
||||||
|
[
|
||||||
|
{ name: 'a', multiple: false, optional: false, options: [], value: 'a' },
|
||||||
|
{ name: 'b', multiple: true, optional: false, options: [], value: 'b' },
|
||||||
|
{ name: 'b', multiple: true, optional: true, options: [], value: 'b2' },
|
||||||
|
{ name: 'b', multiple: true, optional: true, options: [], value: '' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds an extra param to allo adding multiple values if all params are filled in', () => {
|
||||||
|
assertFunctionInstance(
|
||||||
|
funcDef,
|
||||||
|
['a', 'b'],
|
||||||
|
[
|
||||||
|
{ name: 'a', multiple: false, optional: false, options: [], value: 'a' },
|
||||||
|
{ name: 'b', multiple: true, optional: false, options: [], value: 'b' },
|
||||||
|
{ name: 'b', multiple: true, optional: true, options: [], value: '' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when there are optional parameters but no multiple values are allowed', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
funcDef = {
|
||||||
|
name: 'twoOptionalNoMultiple',
|
||||||
|
category: 'A',
|
||||||
|
params: [
|
||||||
|
{ name: 'a', type: 'any', optional: false, multiple: false },
|
||||||
|
{ name: 'b', type: 'any', optional: false, multiple: false },
|
||||||
|
{ name: 'c', type: 'any', optional: true, multiple: false },
|
||||||
|
{ name: 'd', type: 'any', optional: true, multiple: false },
|
||||||
|
],
|
||||||
|
defaultParams: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates non-required parameters', () => {
|
||||||
|
assertFunctionInstance(
|
||||||
|
funcDef,
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{ name: 'a', multiple: false, optional: false, options: [], value: '' },
|
||||||
|
{ name: 'b', multiple: false, optional: false, options: [], value: '' },
|
||||||
|
{ name: 'c', multiple: false, optional: true, options: [], value: '' },
|
||||||
|
{ name: 'd', multiple: false, optional: true, options: [], value: '' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills in provided parameters', () => {
|
||||||
|
assertFunctionInstance(
|
||||||
|
funcDef,
|
||||||
|
['a', 'b', 'c', 'd'],
|
||||||
|
[
|
||||||
|
{ name: 'a', multiple: false, optional: false, options: [], value: 'a' },
|
||||||
|
{ name: 'b', multiple: false, optional: false, options: [], value: 'b' },
|
||||||
|
{ name: 'c', multiple: false, optional: true, options: [], value: 'c' },
|
||||||
|
{ name: 'd', multiple: false, optional: true, options: [], value: 'd' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when there are optional parameters and multiple values are allowed', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
funcDef = {
|
||||||
|
name: 'twoOptionalWithMultiple',
|
||||||
|
category: 'A',
|
||||||
|
params: [
|
||||||
|
{ name: 'a', type: 'any', optional: false, multiple: false },
|
||||||
|
{ name: 'b', type: 'any', optional: false, multiple: false },
|
||||||
|
{ name: 'c', type: 'any', optional: true, multiple: false },
|
||||||
|
{ name: 'd', type: 'any', optional: true, multiple: true },
|
||||||
|
],
|
||||||
|
defaultParams: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add extra param to add multiple values if not all params are filled in', () => {
|
||||||
|
assertFunctionInstance(
|
||||||
|
funcDef,
|
||||||
|
['a', 'b', 'c'],
|
||||||
|
[
|
||||||
|
{ name: 'a', multiple: false, optional: false, options: [], value: 'a' },
|
||||||
|
{ name: 'b', multiple: false, optional: false, options: [], value: 'b' },
|
||||||
|
{ name: 'c', multiple: false, optional: true, options: [], value: 'c' },
|
||||||
|
{ name: 'd', multiple: true, optional: true, options: [], value: '' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds an extra param to add multiple values if all params are filled in', () => {
|
||||||
|
assertFunctionInstance(
|
||||||
|
funcDef,
|
||||||
|
['a', 'b', 'c', 'd'],
|
||||||
|
[
|
||||||
|
{ name: 'a', multiple: false, optional: false, options: [], value: 'a' },
|
||||||
|
{ name: 'b', multiple: false, optional: false, options: [], value: 'b' },
|
||||||
|
{ name: 'c', multiple: false, optional: true, options: [], value: 'c' },
|
||||||
|
{ name: 'd', multiple: true, optional: true, options: [], value: 'd' },
|
||||||
|
{ name: 'd', multiple: true, optional: true, options: [], value: '' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { mapFuncDefsToSelectables } from './helpers';
|
|
||||||
import { FuncDefs } from '../gfunc';
|
|
||||||
|
|
||||||
describe('Graphite components helpers', () => {
|
|
||||||
it('converts function definitions to selectable options', function () {
|
|
||||||
const functionDefs: FuncDefs = {
|
|
||||||
functionA1: { name: 'functionA1', category: 'A', params: [], defaultParams: [] },
|
|
||||||
functionB1: { name: 'functionB1', category: 'B', params: [], defaultParams: [] },
|
|
||||||
functionA2: { name: 'functionA2', category: 'A', params: [], defaultParams: [] },
|
|
||||||
functionB2: { name: 'functionB2', category: 'B', params: [], defaultParams: [] },
|
|
||||||
};
|
|
||||||
const options = mapFuncDefsToSelectables(functionDefs);
|
|
||||||
|
|
||||||
expect(options).toMatchObject([
|
|
||||||
{
|
|
||||||
label: 'A',
|
|
||||||
options: [
|
|
||||||
{ label: 'functionA1', value: 'functionA1' },
|
|
||||||
{ label: 'functionA2', value: 'functionA2' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'B',
|
|
||||||
options: [
|
|
||||||
{ label: 'functionB1', value: 'functionB1' },
|
|
||||||
{ label: 'functionB2', value: 'functionB2' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { FuncDefs, FuncInstance, ParamDef } from '../gfunc';
|
||||||
|
import { forEach, sortBy } from 'lodash';
|
||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
import { EditableParam } from './FunctionParamEditor';
|
||||||
|
|
||||||
|
export function mapFuncDefsToSelectables(funcDefs: FuncDefs): Array<SelectableValue<string>> {
|
||||||
|
const categories: any = {};
|
||||||
|
|
||||||
|
forEach(funcDefs, (funcDef) => {
|
||||||
|
if (!funcDef.category) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!categories[funcDef.category]) {
|
||||||
|
categories[funcDef.category] = { label: funcDef.category, value: funcDef.category, options: [] };
|
||||||
|
}
|
||||||
|
categories[funcDef.category].options.push({
|
||||||
|
label: funcDef.name,
|
||||||
|
value: funcDef.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortBy(categories, 'label');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEditableParam(paramDef: ParamDef, additional: boolean, value?: string | number): EditableParam {
|
||||||
|
return {
|
||||||
|
name: paramDef.name,
|
||||||
|
value: value?.toString() || '',
|
||||||
|
optional: !!paramDef.optional || additional, // only first param is required when multiple are allowed
|
||||||
|
multiple: !!paramDef.multiple,
|
||||||
|
options:
|
||||||
|
paramDef.options?.map((option: string | number) => ({
|
||||||
|
value: option.toString(),
|
||||||
|
label: option.toString(),
|
||||||
|
})) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a list of params that can be edited in the function editor.
|
||||||
|
*/
|
||||||
|
export function mapFuncInstanceToParams(func: FuncInstance): EditableParam[] {
|
||||||
|
// list of required parameters (from func.def)
|
||||||
|
const params: EditableParam[] = func.def.params.map((paramDef: ParamDef, index: number) =>
|
||||||
|
createEditableParam(paramDef, false, func.params[index])
|
||||||
|
);
|
||||||
|
|
||||||
|
// list of additional (multiple or optional) params entered by the user
|
||||||
|
while (params.length < func.params.length) {
|
||||||
|
const paramDef = func.def.params[func.def.params.length - 1];
|
||||||
|
const value = func.params[params.length];
|
||||||
|
params.push(createEditableParam(paramDef, true, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// extra "fake" param to allow adding more multiple values at the end
|
||||||
|
if (params.length && params[params.length - 1].value && params[params.length - 1]?.multiple) {
|
||||||
|
const paramDef = func.def.params[func.def.params.length - 1];
|
||||||
|
params.push(createEditableParam(paramDef, true, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { FuncDefs } from '../gfunc';
|
|
||||||
import { forEach, sortBy } from 'lodash';
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
|
||||||
|
|
||||||
export function mapFuncDefsToSelectables(funcDefs: FuncDefs): Array<SelectableValue<string>> {
|
|
||||||
const categories: any = {};
|
|
||||||
|
|
||||||
forEach(funcDefs, (funcDef) => {
|
|
||||||
if (!funcDef.category) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!categories[funcDef.category]) {
|
|
||||||
categories[funcDef.category] = { label: funcDef.category, value: funcDef.category, options: [] };
|
|
||||||
}
|
|
||||||
categories[funcDef.category].options.push({
|
|
||||||
label: funcDef.name,
|
|
||||||
value: funcDef.name,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return sortBy(categories, 'label');
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
import { assign, clone, each, last, map, partial } from 'lodash';
|
|
||||||
import $ from 'jquery';
|
|
||||||
import coreModule from 'app/core/core_module';
|
|
||||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
|
||||||
import { actions } from './state/actions';
|
|
||||||
|
|
||||||
/** @ngInject */
|
|
||||||
export function graphiteFuncEditor($compile: any, templateSrv: TemplateSrv) {
|
|
||||||
const funcSpanTemplate = `
|
|
||||||
<function-editor
|
|
||||||
func="func"
|
|
||||||
onRemove="ctrl.handleRemoveFunction"
|
|
||||||
onMoveLeft="ctrl.handleMoveLeft"
|
|
||||||
onMoveRight="ctrl.handleMoveRight">
|
|
||||||
</function-editor>
|
|
||||||
<span>(</span>
|
|
||||||
`;
|
|
||||||
const paramTemplate =
|
|
||||||
'<input type="text" style="display:none"' + ' class="input-small tight-form-func-param"></input>';
|
|
||||||
|
|
||||||
return {
|
|
||||||
restrict: 'A',
|
|
||||||
link: function postLink($scope: any, elem: JQuery) {
|
|
||||||
const $funcLink = $(funcSpanTemplate);
|
|
||||||
const ctrl = $scope.ctrl;
|
|
||||||
const func = $scope.func;
|
|
||||||
let scheduledRelink = false;
|
|
||||||
let paramCountAtLink = 0;
|
|
||||||
let cancelBlur: any = null;
|
|
||||||
|
|
||||||
ctrl.handleRemoveFunction = (func: any) => {
|
|
||||||
ctrl.dispatch(actions.removeFunction({ func }));
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.handleMoveLeft = (func: any) => {
|
|
||||||
ctrl.dispatch(actions.moveFunction({ func, offset: -1 }));
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.handleMoveRight = (func: any) => {
|
|
||||||
ctrl.dispatch(actions.moveFunction({ func, offset: 1 }));
|
|
||||||
};
|
|
||||||
|
|
||||||
function clickFuncParam(this: any, paramIndex: any) {
|
|
||||||
const $link = $(this);
|
|
||||||
const $comma = $link.prev('.comma');
|
|
||||||
const $input = $link.next();
|
|
||||||
|
|
||||||
$input.val(func.params[paramIndex]);
|
|
||||||
|
|
||||||
$comma.removeClass('query-part__last');
|
|
||||||
$link.hide();
|
|
||||||
$input.show();
|
|
||||||
$input.focus();
|
|
||||||
$input.select();
|
|
||||||
|
|
||||||
const typeahead = $input.data('typeahead');
|
|
||||||
if (typeahead) {
|
|
||||||
$input.val('');
|
|
||||||
typeahead.lookup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduledRelinkIfNeeded() {
|
|
||||||
if (paramCountAtLink === func.params.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!scheduledRelink) {
|
|
||||||
scheduledRelink = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
relink();
|
|
||||||
scheduledRelink = false;
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function paramDef(index: number) {
|
|
||||||
if (index < func.def.params.length) {
|
|
||||||
return func.def.params[index];
|
|
||||||
}
|
|
||||||
if ((last(func.def.params) as any).multiple) {
|
|
||||||
return assign({}, last(func.def.params), { optional: true });
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchToLink(inputElem: HTMLElement, paramIndex: any) {
|
|
||||||
const $input = $(inputElem);
|
|
||||||
|
|
||||||
clearTimeout(cancelBlur);
|
|
||||||
cancelBlur = null;
|
|
||||||
|
|
||||||
const $link = $input.prev();
|
|
||||||
const $comma = $link.prev('.comma');
|
|
||||||
const newValue = $input.val() as any;
|
|
||||||
|
|
||||||
// remove optional empty params
|
|
||||||
if (newValue !== '' || paramDef(paramIndex).optional) {
|
|
||||||
func.updateParam(newValue, paramIndex);
|
|
||||||
$link.html(newValue ? templateSrv.highlightVariablesAsHtml(newValue) : ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduledRelinkIfNeeded();
|
|
||||||
|
|
||||||
$scope.$apply(() => {
|
|
||||||
// WIP: at the moment function params are mutated directly by func_editor
|
|
||||||
// after migrating to react it will be done by passing param value to
|
|
||||||
// updateFunctionParam action
|
|
||||||
ctrl.dispatch(actions.updateFunctionParam({ func }));
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($link.hasClass('query-part__last') && newValue === '') {
|
|
||||||
$comma.addClass('query-part__last');
|
|
||||||
} else {
|
|
||||||
$link.removeClass('query-part__last');
|
|
||||||
}
|
|
||||||
|
|
||||||
$input.hide();
|
|
||||||
$link.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
// this = input element
|
|
||||||
function inputBlur(this: any, paramIndex: any) {
|
|
||||||
const inputElem = this;
|
|
||||||
// happens long before the click event on the typeahead options
|
|
||||||
// need to have long delay because the blur
|
|
||||||
cancelBlur = setTimeout(() => {
|
|
||||||
switchToLink(inputElem, paramIndex);
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inputKeyPress(this: any, paramIndex: any, e: any) {
|
|
||||||
if (e.which === 13) {
|
|
||||||
$(this).blur();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function inputKeyDown(this: any) {
|
|
||||||
this.style.width = (3 + this.value.length) * 8 + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTypeahead($input: any, paramIndex: any) {
|
|
||||||
$input.attr('data-provide', 'typeahead');
|
|
||||||
|
|
||||||
let options = paramDef(paramIndex).options;
|
|
||||||
if (paramDef(paramIndex).type === 'int') {
|
|
||||||
options = map(options, (val) => {
|
|
||||||
return val.toString();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$input.typeahead({
|
|
||||||
source: options,
|
|
||||||
minLength: 0,
|
|
||||||
items: 20,
|
|
||||||
updater: (value: any) => {
|
|
||||||
$input.val(value);
|
|
||||||
switchToLink($input[0], paramIndex);
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const typeahead = $input.data('typeahead');
|
|
||||||
typeahead.lookup = function () {
|
|
||||||
this.query = this.$element.val() || '';
|
|
||||||
return this.process(this.source);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function addElementsAndCompile() {
|
|
||||||
$funcLink.appendTo(elem);
|
|
||||||
|
|
||||||
if (func.def.unknown) {
|
|
||||||
elem.addClass('unknown-function');
|
|
||||||
}
|
|
||||||
|
|
||||||
const defParams: any = clone(func.def.params);
|
|
||||||
const lastParam: any = last(func.def.params);
|
|
||||||
|
|
||||||
while (func.params.length >= defParams.length && lastParam && lastParam.multiple) {
|
|
||||||
defParams.push(assign({}, lastParam, { optional: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
each(defParams, (param: any, index: number) => {
|
|
||||||
if (param.optional && func.params.length < index) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let paramValue = templateSrv.highlightVariablesAsHtml(func.params[index]);
|
|
||||||
const hasValue = paramValue !== null && paramValue !== undefined && paramValue !== '';
|
|
||||||
const last = index >= func.params.length - 1 && param.optional && !hasValue;
|
|
||||||
let linkClass = 'query-part__link';
|
|
||||||
|
|
||||||
if (last) {
|
|
||||||
linkClass += ' query-part__last';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last && param.multiple) {
|
|
||||||
paramValue = '+';
|
|
||||||
} else if (!hasValue) {
|
|
||||||
// for params with no value default to param name
|
|
||||||
paramValue = param.name;
|
|
||||||
linkClass += ' query-part__link--no-value';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index > 0) {
|
|
||||||
$('<span class="comma' + (last ? ' query-part__last' : '') + '">, </span>').appendTo(elem);
|
|
||||||
}
|
|
||||||
|
|
||||||
const $paramLink = $(`<a ng-click="" class="${linkClass}">${paramValue}</a>`);
|
|
||||||
const $input = $(paramTemplate);
|
|
||||||
$input.attr('placeholder', param.name);
|
|
||||||
|
|
||||||
paramCountAtLink++;
|
|
||||||
|
|
||||||
$paramLink.appendTo(elem);
|
|
||||||
$input.appendTo(elem);
|
|
||||||
|
|
||||||
$input.blur(partial(inputBlur, index));
|
|
||||||
$input.keyup(inputKeyDown);
|
|
||||||
$input.keypress(partial(inputKeyPress, index));
|
|
||||||
$paramLink.click(partial(clickFuncParam, index));
|
|
||||||
|
|
||||||
if (param.options) {
|
|
||||||
addTypeahead($input, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
$('<span>)</span>').appendTo(elem);
|
|
||||||
|
|
||||||
$compile(elem.contents())($scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ifJustAddedFocusFirstParam() {
|
|
||||||
if ($scope.func.added) {
|
|
||||||
$scope.func.added = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
elem.find('.query-part__link').first().click();
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function relink() {
|
|
||||||
elem.children().remove();
|
|
||||||
addElementsAndCompile();
|
|
||||||
ifJustAddedFocusFirstParam();
|
|
||||||
}
|
|
||||||
|
|
||||||
relink();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
coreModule.directive('graphiteFuncEditor', graphiteFuncEditor);
|
|
||||||
@@ -2,7 +2,7 @@ import { assign, each, filter, forEach, get, includes, isString, last, map, toSt
|
|||||||
import { isVersionGtOrEq } from 'app/core/utils/version';
|
import { isVersionGtOrEq } from 'app/core/utils/version';
|
||||||
import { InterpolateFunction } from '@grafana/data';
|
import { InterpolateFunction } from '@grafana/data';
|
||||||
|
|
||||||
type ParamDef = {
|
export type ParamDef = {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
options?: Array<string | number>;
|
options?: Array<string | number>;
|
||||||
@@ -990,6 +990,10 @@ export class FuncInstance {
|
|||||||
def: FuncDef;
|
def: FuncDef;
|
||||||
params: Array<string | number>;
|
params: Array<string | number>;
|
||||||
text: any;
|
text: any;
|
||||||
|
/**
|
||||||
|
* True if this function was just added and not edited yet. It's used to focus on first
|
||||||
|
* function param to edit it straight away after adding a function.
|
||||||
|
*/
|
||||||
declare added: boolean;
|
declare added: boolean;
|
||||||
/**
|
/**
|
||||||
* Hidden functions are not displayed in UI but available in text editor
|
* Hidden functions are not displayed in UI but available in text editor
|
||||||
|
|||||||
@@ -192,6 +192,9 @@ export default class GraphiteQuery {
|
|||||||
this.updateRenderedTarget(target, targets);
|
this.updateRenderedTarget(target, targets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clean-up added param
|
||||||
|
this.functions.forEach((func) => (func.added = false));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRenderedTarget(target: { refId: string | number; target: any; targetFull: any }, targets: any) {
|
updateRenderedTarget(target: { refId: string | number; target: any; targetFull: any }, targets: any) {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ng-repeat="func in ctrl.state.queryModel.functions" class="gf-form">
|
<div ng-repeat="func in ctrl.state.queryModel.functions" class="gf-form">
|
||||||
<span graphite-func-editor class="gf-form-label query-part" ng-hide="func.hidden"></span>
|
<graphite-function-editor func="func" dispatch="ctrl.dispatch" ng-hide="func.hidden"></graphite-function-editor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gf-form dropdown">
|
<div class="gf-form dropdown">
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import './func_editor';
|
|
||||||
|
|
||||||
import GraphiteQuery from './graphite_query';
|
import GraphiteQuery from './graphite_query';
|
||||||
import { QueryCtrl } from 'app/plugins/sdk';
|
import { QueryCtrl } from 'app/plugins/sdk';
|
||||||
import { auto } from 'angular';
|
import { auto } from 'angular';
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ const unpause = createAction('unpause');
|
|||||||
const addFunction = createAction<{ name: string }>('add-function');
|
const addFunction = createAction<{ name: string }>('add-function');
|
||||||
const removeFunction = createAction<{ func: FuncInstance }>('remove-function');
|
const removeFunction = createAction<{ func: FuncInstance }>('remove-function');
|
||||||
const moveFunction = createAction<{ func: FuncInstance; offset: number }>('move-function');
|
const moveFunction = createAction<{ func: FuncInstance; offset: number }>('move-function');
|
||||||
// TODO: at the moment parameters are modified directly, new value of the param will be passed in the action
|
const updateFunctionParam = createAction<{ func: FuncInstance; index: number; value: string }>('change-function-param');
|
||||||
const updateFunctionParam = createAction<{ func: FuncInstance }>('update-function-param');
|
|
||||||
|
|
||||||
// Text editor
|
// Text editor
|
||||||
const updateQuery = createAction<{ query: string }>('update-query');
|
const updateQuery = createAction<{ query: string }>('update-query');
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ const reducer = async (action: Action, state: GraphiteQueryEditorState): Promise
|
|||||||
handleTargetChanged(state);
|
handleTargetChanged(state);
|
||||||
}
|
}
|
||||||
if (actions.updateFunctionParam.match(action)) {
|
if (actions.updateFunctionParam.match(action)) {
|
||||||
|
const { func, index, value } = action.payload;
|
||||||
|
func.updateParam(value, index);
|
||||||
handleTargetChanged(state);
|
handleTargetChanged(state);
|
||||||
}
|
}
|
||||||
if (actions.updateQuery.match(action)) {
|
if (actions.updateQuery.match(action)) {
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
.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;
|
||||||
|
|||||||
Reference in New Issue
Block a user