Actions: Add support for custom variables (#105639)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
co-authored by
Leon Sorokin
parent
2ce1c67d92
commit
267e3ffb7c
@@ -814,7 +814,11 @@ export {
|
||||
export {
|
||||
type Action,
|
||||
type ActionModel,
|
||||
type ActionVariable,
|
||||
type ActionVariableInput,
|
||||
ActionType,
|
||||
HttpRequestMethod,
|
||||
ActionVariableType,
|
||||
defaultActionConfig,
|
||||
contentTypeOptions,
|
||||
httpMethodOptions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import { SelectableValue } from './select';
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface Action {
|
||||
[ActionType.Fetch]: FetchOptions;
|
||||
confirmation?: string;
|
||||
oneClick?: boolean;
|
||||
variables?: ActionVariable[];
|
||||
style?: ActionButtonCssProperties;
|
||||
}
|
||||
|
||||
@@ -26,10 +27,21 @@ export interface Action {
|
||||
*/
|
||||
export interface ActionModel<T = any> {
|
||||
title: string;
|
||||
onClick: (event: any, origin?: any) => void;
|
||||
confirmation?: string;
|
||||
onClick: (event: any, origin?: any, actionVars?: ActionVariableInput) => void;
|
||||
confirmation: (actionVars?: ActionVariableInput) => ReactNode;
|
||||
oneClick?: boolean;
|
||||
style: ActionButtonCssProperties;
|
||||
variables?: ActionVariable[];
|
||||
}
|
||||
|
||||
export type ActionVariable = {
|
||||
key: string;
|
||||
name: string;
|
||||
type: ActionVariableType;
|
||||
};
|
||||
|
||||
export enum ActionVariableType {
|
||||
String = 'string',
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
@@ -70,3 +82,5 @@ export const defaultActionConfig: Action = {
|
||||
headers: [['Content-Type', 'application/json']],
|
||||
},
|
||||
};
|
||||
|
||||
export type ActionVariableInput = { [key: string]: string };
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ActionModel, Field } from '@grafana/data';
|
||||
import { ActionModel, Field, ActionVariableInput } from '@grafana/data';
|
||||
|
||||
import { useTheme2 } from '../../themes';
|
||||
import { t } from '../../utils/i18n';
|
||||
import { Button, ButtonProps } from '../Button';
|
||||
import { ConfirmModal } from '../ConfirmModal/ConfirmModal';
|
||||
|
||||
import { VariablesInputModal } from './VariablesInputModal';
|
||||
|
||||
type ActionButtonProps = ButtonProps & {
|
||||
action: ActionModel<Field>;
|
||||
};
|
||||
@@ -21,27 +23,52 @@ export function ActionButton({ action, ...buttonProps }: ActionButtonProps) {
|
||||
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// Action variables
|
||||
const [showVarsModal, setShowVarsModal] = useState(false);
|
||||
const [actionVars, setActionVars] = useState<ActionVariableInput>({});
|
||||
|
||||
const actionHasVariables = action.variables && action.variables.length > 0;
|
||||
|
||||
const onClick = () => {
|
||||
if (actionHasVariables) {
|
||||
setShowVarsModal(true);
|
||||
} else {
|
||||
setShowConfirm(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
onClick={onClick}
|
||||
{...buttonProps}
|
||||
style={{ width: 'fit-content', backgroundColor, color: textColor }}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
|
||||
{actionHasVariables && showVarsModal && (
|
||||
<VariablesInputModal
|
||||
onDismiss={() => setShowVarsModal(false)}
|
||||
action={action}
|
||||
onShowConfirm={() => setShowConfirm(true)}
|
||||
variables={actionVars}
|
||||
setVariables={setActionVars}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showConfirm && (
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
title={t('grafana-ui.action-editor.button.confirm-action', 'Confirm action')}
|
||||
body={action.confirmation}
|
||||
body={action.confirmation(actionVars)}
|
||||
confirmText={t('grafana-ui.action-editor.button.confirm', 'Confirm')}
|
||||
confirmButtonVariant="primary"
|
||||
onConfirm={() => {
|
||||
setShowConfirm(false);
|
||||
action.onClick(new MouseEvent('click'));
|
||||
action.onClick(new MouseEvent('click'), null, actionVars);
|
||||
}}
|
||||
onDismiss={() => {
|
||||
setShowConfirm(false);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { ActionModel, ActionVariableInput } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { t } from '../../utils/i18n';
|
||||
import { Button } from '../Button/Button';
|
||||
import { Field } from '../Forms/Field';
|
||||
import { FieldSet } from '../Forms/FieldSet';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Modal } from '../Modal/Modal';
|
||||
|
||||
interface Props {
|
||||
action: ActionModel;
|
||||
onDismiss: () => void;
|
||||
onShowConfirm: () => void;
|
||||
variables: ActionVariableInput;
|
||||
setVariables: (vars: ActionVariableInput) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function VariablesInputModal({ action, onDismiss, onShowConfirm, variables, setVariables }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const onModalContinue = () => {
|
||||
onDismiss();
|
||||
onShowConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
title={t('grafana-ui.action-editor.button.action-variables-title', 'Action variables')}
|
||||
onDismiss={onDismiss}
|
||||
className={styles.variablesModal}
|
||||
>
|
||||
<FieldSet>
|
||||
{action.variables!.map((variable) => (
|
||||
<Field key={variable.name} label={variable.name}>
|
||||
<Input
|
||||
type="text"
|
||||
value={variables[variable.key] ?? ''}
|
||||
onChange={(e) => {
|
||||
setVariables({ ...variables, [variable.key]: e.currentTarget.value });
|
||||
}}
|
||||
placeholder={t('grafana-ui.action-editor.button.variable-value-placeholder', 'Value')}
|
||||
width={20}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</FieldSet>
|
||||
<Modal.ButtonRow>
|
||||
<Button variant="secondary" onClick={onDismiss}>
|
||||
{t('grafana-ui.action-editor.close', 'Close')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onModalContinue}>
|
||||
{t('grafana-ui.action-editor.continue', 'Continue')}
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
variablesModal: css({
|
||||
zIndex: 10000,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -19,6 +19,7 @@ describe('DataLinksActionsTooltip', () => {
|
||||
const mockAction: ActionModel = {
|
||||
title: 'Action1',
|
||||
onClick: jest.fn(),
|
||||
confirmation: jest.fn(),
|
||||
style: { backgroundColor: '#ff0000' },
|
||||
};
|
||||
|
||||
|
||||
@@ -228,8 +228,11 @@ export const TooltipPlugin2 = ({
|
||||
|
||||
// in some ways this is similar to ClickOutsideWrapper.tsx
|
||||
const downEventOutside = (e: Event) => {
|
||||
// this tooltip is Portaled, but actions inside it create forms in Modals
|
||||
const isModalOrPortaled = '[role="dialog"], #grafana-portal-container';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
if (!domRef.current!.contains(e.target as Node)) {
|
||||
if ((e.target as HTMLElement).closest(isModalOrPortaled) == null) {
|
||||
dismiss();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { Action, GrafanaTheme2, httpMethodOptions, HttpRequestMethod, VariableSuggestion } from '@grafana/data';
|
||||
import {
|
||||
Action,
|
||||
GrafanaTheme2,
|
||||
httpMethodOptions,
|
||||
HttpRequestMethod,
|
||||
VariableSuggestion,
|
||||
ActionVariable,
|
||||
} from '@grafana/data';
|
||||
import { useTranslate } from '@grafana/i18n';
|
||||
import {
|
||||
Switch,
|
||||
@@ -17,6 +24,7 @@ import {
|
||||
|
||||
import { HTMLElementType, SuggestionsInput } from '../transformers/suggestionsInput/SuggestionsInput';
|
||||
|
||||
import { ActionVariablesEditor } from './ActionVariablesEditor';
|
||||
import { ParamsEditor } from './ParamsEditor';
|
||||
|
||||
interface ActionEditorProps {
|
||||
@@ -75,6 +83,13 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne
|
||||
});
|
||||
};
|
||||
|
||||
const onVariablesChange = (variables: ActionVariable[]) => {
|
||||
onChange(index, {
|
||||
...value,
|
||||
variables,
|
||||
});
|
||||
};
|
||||
|
||||
const onQueryParamsChange = (queryParams: Array<[string, string]>) => {
|
||||
onChange(index, {
|
||||
...value,
|
||||
@@ -205,6 +220,14 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
|
||||
<Field
|
||||
label={t('grafana-ui.action-editor.modal.action-variables', 'Variables')}
|
||||
className={styles.fieldGap}
|
||||
noMargin
|
||||
>
|
||||
<ActionVariablesEditor onChange={onVariablesChange} value={value.variables ?? []} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t('grafana-ui.action-editor.modal.action-query-params', 'Query parameters')}
|
||||
className={styles.fieldGap}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ActionVariableType } from '@grafana/data';
|
||||
|
||||
import { ActionVariablesEditor } from './ActionVariablesEditor';
|
||||
|
||||
describe('ActionVariablesEditor', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnChange.mockClear();
|
||||
});
|
||||
|
||||
it('renders empty state correctly', () => {
|
||||
render(<ActionVariablesEditor value={[]} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Key')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Type')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables add button when both key and name are filled', async () => {
|
||||
render(<ActionVariablesEditor value={[]} onChange={mockOnChange} />);
|
||||
|
||||
const keyInput = screen.getByPlaceholderText('Key');
|
||||
const nameInput = screen.getByPlaceholderText('Name');
|
||||
const addButton = screen.getByRole('button', { name: 'Add' });
|
||||
|
||||
expect(addButton).toBeDisabled();
|
||||
|
||||
await userEvent.type(keyInput, 'testKey');
|
||||
await userEvent.type(nameInput, 'testName');
|
||||
|
||||
expect(addButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds a new variable when add button is clicked', async () => {
|
||||
render(<ActionVariablesEditor value={[]} onChange={mockOnChange} />);
|
||||
|
||||
const keyInput = screen.getByPlaceholderText('Key');
|
||||
const nameInput = screen.getByPlaceholderText('Name');
|
||||
const addButton = screen.getByRole('button', { name: 'Add' });
|
||||
|
||||
await userEvent.type(keyInput, 'testKey');
|
||||
await userEvent.type(nameInput, 'testName');
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith([
|
||||
{
|
||||
key: 'testKey',
|
||||
name: 'testName',
|
||||
type: ActionVariableType.String,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(keyInput).toHaveValue('');
|
||||
expect(nameInput).toHaveValue('');
|
||||
});
|
||||
|
||||
it('removes a variable when delete button is clicked', async () => {
|
||||
const existingVariables = [
|
||||
{ key: 'key1', name: 'name1', type: ActionVariableType.String },
|
||||
{ key: 'key2', name: 'name2', type: ActionVariableType.String },
|
||||
];
|
||||
|
||||
render(<ActionVariablesEditor value={existingVariables} onChange={mockOnChange} />);
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' });
|
||||
await userEvent.click(deleteButtons[0]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith([{ key: 'key2', name: 'name2', type: ActionVariableType.String }]);
|
||||
});
|
||||
|
||||
it('sorts variables by key when adding new ones', async () => {
|
||||
const existingVariables = [{ key: 'key2', name: 'name2', type: ActionVariableType.String }];
|
||||
|
||||
render(<ActionVariablesEditor value={existingVariables} onChange={mockOnChange} />);
|
||||
|
||||
const keyInput = screen.getByPlaceholderText('Key');
|
||||
const nameInput = screen.getByPlaceholderText('Name');
|
||||
const addButton = screen.getByRole('button', { name: 'Add' });
|
||||
|
||||
await userEvent.type(keyInput, 'key1');
|
||||
await userEvent.type(nameInput, 'name1');
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith([
|
||||
{ key: 'key1', name: 'name1', type: ActionVariableType.String },
|
||||
{ key: 'key2', name: 'name2', type: ActionVariableType.String },
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates existing variable when adding with same key', async () => {
|
||||
const existingVariables = [{ key: 'key1', name: 'oldName', type: ActionVariableType.String }];
|
||||
|
||||
render(<ActionVariablesEditor value={existingVariables} onChange={mockOnChange} />);
|
||||
|
||||
const keyInput = screen.getByPlaceholderText('Key');
|
||||
const nameInput = screen.getByPlaceholderText('Name');
|
||||
const addButton = screen.getByRole('button', { name: 'Add' });
|
||||
|
||||
await userEvent.type(keyInput, 'key1');
|
||||
await userEvent.type(nameInput, 'newName');
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith([{ key: 'key1', name: 'newName', type: ActionVariableType.String }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ActionVariable, ActionVariableType } from '@grafana/data';
|
||||
import { useTranslate } from '@grafana/i18n';
|
||||
import { IconButton, Input, Stack, Combobox, ComboboxOption } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
onChange: (v: ActionVariable[]) => void;
|
||||
value: ActionVariable[];
|
||||
}
|
||||
|
||||
export const ActionVariablesEditor = ({ value, onChange }: Props) => {
|
||||
const [key, setKey] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState<ActionVariableType>(ActionVariableType.String);
|
||||
|
||||
const { t } = useTranslate();
|
||||
|
||||
const changeKey = (key: string) => {
|
||||
setKey(key);
|
||||
};
|
||||
|
||||
const changeName = (name: string) => {
|
||||
setName(name);
|
||||
};
|
||||
|
||||
const changeType = (type: ComboboxOption) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
setType(type.value as ActionVariableType);
|
||||
};
|
||||
|
||||
const addVariable = () => {
|
||||
let newVariables: ActionVariable[];
|
||||
|
||||
if (value) {
|
||||
newVariables = value.filter((e) => e.key !== key);
|
||||
} else {
|
||||
newVariables = [];
|
||||
}
|
||||
|
||||
newVariables.push({ key, name, type });
|
||||
newVariables.sort((a, b) => a.key.localeCompare(b.key));
|
||||
onChange(newVariables);
|
||||
|
||||
setKey('');
|
||||
setName('');
|
||||
setType(ActionVariableType.String);
|
||||
};
|
||||
|
||||
const removeVariable = (key: string) => () => {
|
||||
const updatedVariables = value.filter((variable) => variable.key !== key);
|
||||
onChange(updatedVariables);
|
||||
};
|
||||
|
||||
const isAddButtonDisabled = name === '' || key === '';
|
||||
|
||||
const variableTypeOptions: ComboboxOption[] = [{ label: 'string', value: ActionVariableType.String }];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Stack direction="row">
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(e) => changeKey(e.currentTarget.value)}
|
||||
placeholder={t('actions.params-editor.placeholder-key', 'Key')}
|
||||
width={300}
|
||||
/>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => changeName(e.currentTarget.value)}
|
||||
placeholder={t('actions.params-editor.placeholder-name', 'Name')}
|
||||
width={300}
|
||||
/>
|
||||
<Combobox
|
||||
value={type}
|
||||
onChange={changeType}
|
||||
placeholder={t('actions.variables-editor.placeholder-type', 'Type')}
|
||||
options={variableTypeOptions}
|
||||
maxWidth={100}
|
||||
minWidth={10}
|
||||
width={'auto'}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label={t('actions.params-editor.aria-label-add', 'Add')}
|
||||
name="plus-circle"
|
||||
onClick={() => addVariable()}
|
||||
disabled={isAddButtonDisabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="column">
|
||||
{value.map((entry) => (
|
||||
<Stack key={entry.key} direction="row">
|
||||
<Input disabled value={entry.key} width={300} />
|
||||
<Input disabled value={entry.name} width={300} />
|
||||
<Input disabled value={entry.type} width={100} />
|
||||
<IconButton
|
||||
aria-label={t('actions.params-editor.aria-label-delete', 'Delete')}
|
||||
onClick={removeVariable(entry.key)}
|
||||
name="trash-alt"
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Action, ActionType, ActionVariableInput, ActionVariableType } from '@grafana/data';
|
||||
|
||||
import { HttpRequestMethod } from '../../plugins/panel/canvas/panelcfg.gen';
|
||||
|
||||
import { buildActionRequest, genReplaceActionVars } from './utils';
|
||||
|
||||
describe('interpolateActionVariables', () => {
|
||||
const actionMock = (): Action => ({
|
||||
title: 'Thermostat Control',
|
||||
type: ActionType.Fetch,
|
||||
variables: [
|
||||
{ key: 'thermostat1', name: 'First Floor Thermostat', type: ActionVariableType.String },
|
||||
{ key: 'thermostat2', name: 'Second Floor Thermostat', type: ActionVariableType.String },
|
||||
],
|
||||
fetch: {
|
||||
url: 'http://test.com/api/thermostats/$thermostat1/sync/$thermostat2',
|
||||
method: HttpRequestMethod.POST,
|
||||
body: JSON.stringify({
|
||||
primary: 'Device-$thermostat1',
|
||||
data: {
|
||||
secondary: 'Room-$thermostat2',
|
||||
settings: {
|
||||
syncMode: 'Mode-$thermostat1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
headers: [
|
||||
['Device-ID', 'Thermostat-$thermostat1'],
|
||||
['Content-Type', 'application/json'],
|
||||
],
|
||||
queryParams: [
|
||||
['primary', 'Device-$thermostat1'],
|
||||
['secondary', 'Room-$thermostat2'],
|
||||
['mode', 'sync'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
it('should return original action if no variables or actionVars are provided', () => {
|
||||
const action: Action = {
|
||||
title: 'Test',
|
||||
type: ActionType.Fetch,
|
||||
[ActionType.Fetch]: {
|
||||
url: 'http://test.com/api/',
|
||||
method: HttpRequestMethod.GET,
|
||||
},
|
||||
};
|
||||
const actionVars: ActionVariableInput = {};
|
||||
|
||||
const request = buildActionRequest(
|
||||
action,
|
||||
genReplaceActionVars((str) => str, action, actionVars)
|
||||
);
|
||||
expect(request).toEqual({
|
||||
headers: {
|
||||
'X-Grafana-Action': '1',
|
||||
},
|
||||
method: 'GET',
|
||||
url: 'http://test.com/api/',
|
||||
});
|
||||
});
|
||||
|
||||
it('should interpolate variables in URL', () => {
|
||||
const action = actionMock();
|
||||
const actionVars: ActionVariableInput = {
|
||||
thermostat1: 'T-001',
|
||||
thermostat2: 'T-002',
|
||||
};
|
||||
|
||||
const request = buildActionRequest(
|
||||
action,
|
||||
genReplaceActionVars((str) => str, action, actionVars)
|
||||
);
|
||||
expect(request.url).toBe(
|
||||
'http://test.com/api/thermostats/T-001/sync/T-002?primary=Device-T-001&secondary=Room-T-002&mode=sync'
|
||||
);
|
||||
});
|
||||
|
||||
it('should interpolate variables in request body', () => {
|
||||
const action = actionMock();
|
||||
const actionVars: ActionVariableInput = {
|
||||
thermostat1: 'T-001',
|
||||
thermostat2: 'T-002',
|
||||
};
|
||||
|
||||
const request = buildActionRequest(
|
||||
action,
|
||||
genReplaceActionVars((str) => str, action, actionVars)
|
||||
);
|
||||
expect(JSON.parse(request.data)).toEqual({
|
||||
primary: 'Device-T-001',
|
||||
data: {
|
||||
secondary: 'Room-T-002',
|
||||
settings: {
|
||||
syncMode: 'Mode-T-001',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should interpolate variables in headers', () => {
|
||||
const action = actionMock();
|
||||
const actionVars: ActionVariableInput = {
|
||||
thermostat1: 'T-001',
|
||||
thermostat2: 'T-002',
|
||||
};
|
||||
|
||||
const request = buildActionRequest(
|
||||
action,
|
||||
genReplaceActionVars((str) => str, action, actionVars)
|
||||
);
|
||||
expect(request.headers).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
'Device-ID': 'Thermostat-T-001',
|
||||
'X-Grafana-Action': '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should interpolate variables in query params', () => {
|
||||
const action = actionMock();
|
||||
const actionVars: ActionVariableInput = {
|
||||
thermostat1: 'T-001',
|
||||
thermostat2: 'T-002',
|
||||
};
|
||||
|
||||
const request = buildActionRequest(
|
||||
action,
|
||||
genReplaceActionVars((str) => str, action, actionVars)
|
||||
);
|
||||
expect(request.url).toEqual(
|
||||
'http://test.com/api/thermostats/T-001/sync/T-002?primary=Device-T-001&secondary=Room-T-002&mode=sync'
|
||||
);
|
||||
});
|
||||
|
||||
it('should only interpolate provided variables', () => {
|
||||
const action = actionMock();
|
||||
const actionVars: ActionVariableInput = {
|
||||
thermostat1: 'T-001',
|
||||
// thermostat2 is not provided
|
||||
};
|
||||
|
||||
const request = buildActionRequest(
|
||||
action,
|
||||
genReplaceActionVars((str) => str, action, actionVars)
|
||||
);
|
||||
expect(request.url).toBe(
|
||||
'http://test.com/api/thermostats/T-001/sync/$thermostat2?primary=Device-T-001&secondary=Room-%24thermostat2&mode=sync'
|
||||
);
|
||||
expect(JSON.parse(request.data).data.secondary).toBe('Room-$thermostat2');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Action,
|
||||
ActionModel,
|
||||
ActionVariableInput,
|
||||
AppEvents,
|
||||
DataContextScopedVar,
|
||||
DataFrame,
|
||||
@@ -13,12 +14,35 @@ import {
|
||||
textUtil,
|
||||
ValueLinkConfig,
|
||||
} from '@grafana/data';
|
||||
import { BackendSrvRequest, getBackendSrv, config as grafanaConfig } from '@grafana/runtime';
|
||||
import { BackendSrvRequest, config as grafanaConfig, getBackendSrv } from '@grafana/runtime';
|
||||
import { appEvents } from 'app/core/core';
|
||||
|
||||
import { HttpRequestMethod } from '../../plugins/panel/canvas/panelcfg.gen';
|
||||
import { createAbsoluteUrl, RelativeUrl } from '../alerting/unified/utils/url';
|
||||
|
||||
/** @internal */
|
||||
export const genReplaceActionVars = (
|
||||
boundReplaceVariables: InterpolateFunction,
|
||||
action: Action,
|
||||
actionVars?: ActionVariableInput
|
||||
): InterpolateFunction => {
|
||||
return (value, scopedVars, format) => {
|
||||
if (action.variables && actionVars) {
|
||||
value = value.replace(/\$\w+/g, (matched) => {
|
||||
const name = matched.slice(1);
|
||||
|
||||
if (action.variables!.some((action) => action.key === name) && actionVars[name] != null) {
|
||||
return actionVars[name];
|
||||
}
|
||||
|
||||
return matched;
|
||||
});
|
||||
}
|
||||
|
||||
return boundReplaceVariables(value, scopedVars, format);
|
||||
};
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export const getActions = (
|
||||
frame: DataFrame,
|
||||
@@ -50,22 +74,40 @@ export const getActions = (
|
||||
dataContext.value.calculatedValue = config.calculatedValue;
|
||||
}
|
||||
|
||||
const title = replaceVariables(action.title, actionScopedVars);
|
||||
const confirmation = replaceVariables(
|
||||
action.confirmation || `Are you sure you want to ${action.title}?`,
|
||||
actionScopedVars
|
||||
);
|
||||
|
||||
const actionModel: ActionModel<Field> = {
|
||||
title,
|
||||
confirmation,
|
||||
onClick: (evt: MouseEvent, origin: Field) => {
|
||||
buildActionOnClick(action, boundReplaceVariables);
|
||||
title: replaceVariables(action.title, actionScopedVars),
|
||||
confirmation: (actionVars?: ActionVariableInput) =>
|
||||
genReplaceActionVars(
|
||||
boundReplaceVariables,
|
||||
action,
|
||||
actionVars
|
||||
)(action.confirmation || `Are you sure you want to ${action.title}?`),
|
||||
onClick: (evt: MouseEvent, origin: Field, actionVars?: ActionVariableInput) => {
|
||||
let request = buildActionRequest(action, genReplaceActionVars(boundReplaceVariables, action, actionVars));
|
||||
|
||||
try {
|
||||
getBackendSrv()
|
||||
.fetch(request)
|
||||
.subscribe({
|
||||
error: (error) => {
|
||||
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
|
||||
console.error(error);
|
||||
},
|
||||
complete: () => {
|
||||
appEvents.emit(AppEvents.alertSuccess, ['API call was successful']);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
},
|
||||
oneClick: action.oneClick ?? false,
|
||||
style: {
|
||||
backgroundColor: action.style?.backgroundColor ?? grafanaConfig.theme2.colors.secondary.main,
|
||||
},
|
||||
variables: action.variables,
|
||||
};
|
||||
|
||||
return actionModel;
|
||||
@@ -75,52 +117,36 @@ export const getActions = (
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
const buildActionOnClick = (action: Action, replaceVariables: InterpolateFunction) => {
|
||||
try {
|
||||
const url = new URL(getUrl(replaceVariables(action.fetch.url)));
|
||||
export const buildActionRequest = (action: Action, replaceVariables: InterpolateFunction) => {
|
||||
const url = new URL(getUrl(replaceVariables(action.fetch.url)));
|
||||
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
|
||||
let request: BackendSrvRequest = {
|
||||
url: url.toString(),
|
||||
method: action.fetch.method,
|
||||
data: getData(action, replaceVariables),
|
||||
headers: requestHeaders,
|
||||
};
|
||||
let request: BackendSrvRequest = {
|
||||
url: url.toString(),
|
||||
method: action.fetch.method,
|
||||
data: getData(action, replaceVariables),
|
||||
headers: requestHeaders,
|
||||
};
|
||||
|
||||
if (action.fetch.headers) {
|
||||
action.fetch.headers.forEach(([name, value]) => {
|
||||
requestHeaders[replaceVariables(name)] = replaceVariables(value);
|
||||
});
|
||||
}
|
||||
|
||||
if (action.fetch.queryParams) {
|
||||
action.fetch.queryParams?.forEach(([name, value]) => {
|
||||
url.searchParams.append(replaceVariables(name), replaceVariables(value));
|
||||
});
|
||||
|
||||
request.url = url.toString();
|
||||
}
|
||||
|
||||
requestHeaders['X-Grafana-Action'] = '1';
|
||||
request.headers = requestHeaders;
|
||||
|
||||
getBackendSrv()
|
||||
.fetch(request)
|
||||
.subscribe({
|
||||
error: (error) => {
|
||||
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
|
||||
console.error(error);
|
||||
},
|
||||
complete: () => {
|
||||
appEvents.emit(AppEvents.alertSuccess, ['API call was successful']);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
|
||||
console.error(error);
|
||||
return;
|
||||
if (action.fetch.headers) {
|
||||
action.fetch.headers.forEach(([name, value]) => {
|
||||
requestHeaders[replaceVariables(name)] = replaceVariables(value);
|
||||
});
|
||||
}
|
||||
|
||||
if (action.fetch.queryParams) {
|
||||
action.fetch.queryParams?.forEach(([name, value]) => {
|
||||
url.searchParams.append(replaceVariables(name), replaceVariables(value));
|
||||
});
|
||||
|
||||
request.url = url.toString();
|
||||
}
|
||||
|
||||
requestHeaders['X-Grafana-Action'] = '1';
|
||||
request.headers = requestHeaders;
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
|
||||
@@ -752,7 +752,7 @@ export class ElementState implements LayerElement {
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
title={t('grafana-ui.action-editor.button.confirm-action', 'Confirm action')}
|
||||
body={action.confirmation}
|
||||
body={action.confirmation(/** TODO: implement actionVars */)}
|
||||
confirmText={t('grafana-ui.action-editor.button.confirm', 'Confirm')}
|
||||
confirmButtonVariant="primary"
|
||||
onConfirm={() => {
|
||||
|
||||
@@ -57,7 +57,11 @@
|
||||
"aria-label-add": "Add",
|
||||
"aria-label-delete": "Delete",
|
||||
"placeholder-key": "Key",
|
||||
"placeholder-name": "Name",
|
||||
"placeholder-value": "Value"
|
||||
},
|
||||
"variables-editor": {
|
||||
"placeholder-type": "Type"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -5404,10 +5408,14 @@
|
||||
"grafana-ui": {
|
||||
"action-editor": {
|
||||
"button": {
|
||||
"action-variables-title": "Action variables",
|
||||
"confirm": "Confirm",
|
||||
"confirm-action": "Confirm action",
|
||||
"style": "Button style"
|
||||
"style": "Button style",
|
||||
"variable-value-placeholder": "Value"
|
||||
},
|
||||
"close": "Close",
|
||||
"continue": "Continue",
|
||||
"inline": {
|
||||
"add-action": "Add action",
|
||||
"edit-action": "Edit action"
|
||||
@@ -5418,6 +5426,7 @@
|
||||
"action-query-params": "Query parameters",
|
||||
"action-title": "Title",
|
||||
"action-title-placeholder": "Action title",
|
||||
"action-variables": "Variables",
|
||||
"one-click-description": "Only one link or action can have one click enabled at a time"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user