mirror of https://github.com/grafana/grafana
Actions: Add support for custom variables (#105639)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>pull/105828/head
parent
2ce1c67d92
commit
267e3ffb7c
@ -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, |
||||
}), |
||||
}; |
||||
}; |
@ -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'); |
||||
}); |
||||
}); |
Loading…
Reference in new issue