Actions: Add support for custom variables (#105639)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
pull/105828/head
Adela Almasan 2 months ago committed by GitHub
parent 2ce1c67d92
commit 267e3ffb7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      packages/grafana-data/src/index.ts
  2. 20
      packages/grafana-data/src/types/action.ts
  3. 35
      packages/grafana-ui/src/components/Actions/ActionButton.tsx
  4. 72
      packages/grafana-ui/src/components/Actions/VariablesInputModal.tsx
  5. 1
      packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx
  6. 5
      packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx
  7. 25
      public/app/features/actions/ActionEditor.tsx
  8. 110
      public/app/features/actions/ActionVariablesEditor.test.tsx
  9. 107
      public/app/features/actions/ActionVariablesEditor.tsx
  10. 151
      public/app/features/actions/utils.test.ts
  11. 126
      public/app/features/actions/utils.ts
  12. 2
      public/app/features/canvas/runtime/element.tsx
  13. 11
      public/locales/en-US/grafana.json

@ -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.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));
});
if (action.fetch.queryParams) {
action.fetch.queryParams?.forEach(([name, value]) => {
url.searchParams.append(replaceVariables(name), replaceVariables(value));
});
request.url = url.toString();
}
request.url = url.toString();
}
requestHeaders['X-Grafana-Action'] = '1';
request.headers = requestHeaders;
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;
}
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"
}
},

Loading…
Cancel
Save