diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 631b4374ab6..2a190301d1d 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -814,7 +814,11 @@ export { export { type Action, type ActionModel, + type ActionVariable, + type ActionVariableInput, + ActionType, HttpRequestMethod, + ActionVariableType, defaultActionConfig, contentTypeOptions, httpMethodOptions, diff --git a/packages/grafana-data/src/types/action.ts b/packages/grafana-data/src/types/action.ts index 06a61e53ecd..3a637ffa071 100644 --- a/packages/grafana-data/src/types/action.ts +++ b/packages/grafana-data/src/types/action.ts @@ -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 { 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 }; diff --git a/packages/grafana-ui/src/components/Actions/ActionButton.tsx b/packages/grafana-ui/src/components/Actions/ActionButton.tsx index 9d2987dc47d..55b8d3baf32 100644 --- a/packages/grafana-ui/src/components/Actions/ActionButton.tsx +++ b/packages/grafana-ui/src/components/Actions/ActionButton.tsx @@ -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; }; @@ -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({}); + + const actionHasVariables = action.variables && action.variables.length > 0; + + const onClick = () => { + if (actionHasVariables) { + setShowVarsModal(true); + } else { + setShowConfirm(true); + } + }; + return ( <> + + {actionHasVariables && showVarsModal && ( + setShowVarsModal(false)} + action={action} + onShowConfirm={() => setShowConfirm(true)} + variables={actionVars} + setVariables={setActionVars} + /> + )} + {showConfirm && ( { setShowConfirm(false); - action.onClick(new MouseEvent('click')); + action.onClick(new MouseEvent('click'), null, actionVars); }} onDismiss={() => { setShowConfirm(false); diff --git a/packages/grafana-ui/src/components/Actions/VariablesInputModal.tsx b/packages/grafana-ui/src/components/Actions/VariablesInputModal.tsx new file mode 100644 index 00000000000..73be7916e72 --- /dev/null +++ b/packages/grafana-ui/src/components/Actions/VariablesInputModal.tsx @@ -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 ( + +
+ {action.variables!.map((variable) => ( + + { + setVariables({ ...variables, [variable.key]: e.currentTarget.value }); + }} + placeholder={t('grafana-ui.action-editor.button.variable-value-placeholder', 'Value')} + width={20} + /> + + ))} +
+ + + + +
+ ); +} + +const getStyles = () => { + return { + variablesModal: css({ + zIndex: 10000, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx index 53bb472f54b..e6918901f2b 100644 --- a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx +++ b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx @@ -19,6 +19,7 @@ describe('DataLinksActionsTooltip', () => { const mockAction: ActionModel = { title: 'Action1', onClick: jest.fn(), + confirmation: jest.fn(), style: { backgroundColor: '#ff0000' }, }; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index b5b0bcef2c4..649e1a377d2 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -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(); } }; diff --git a/public/app/features/actions/ActionEditor.tsx b/public/app/features/actions/ActionEditor.tsx index b9595c8d16a..9e94c088574 100644 --- a/public/app/features/actions/ActionEditor.tsx +++ b/public/app/features/actions/ActionEditor.tsx @@ -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 + + + + { + const mockOnChange = jest.fn(); + + beforeEach(() => { + mockOnChange.mockClear(); + }); + + it('renders empty state correctly', () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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 }]); + }); +}); diff --git a/public/app/features/actions/ActionVariablesEditor.tsx b/public/app/features/actions/ActionVariablesEditor.tsx new file mode 100644 index 00000000000..7c264b53a6c --- /dev/null +++ b/public/app/features/actions/ActionVariablesEditor.tsx @@ -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.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 ( +
+ + changeKey(e.currentTarget.value)} + placeholder={t('actions.params-editor.placeholder-key', 'Key')} + width={300} + /> + changeName(e.currentTarget.value)} + placeholder={t('actions.params-editor.placeholder-name', 'Name')} + width={300} + /> + + addVariable()} + disabled={isAddButtonDisabled} + /> + + + + {value.map((entry) => ( + + + + + + + ))} + +
+ ); +}; diff --git a/public/app/features/actions/utils.test.ts b/public/app/features/actions/utils.test.ts new file mode 100644 index 00000000000..959128f908c --- /dev/null +++ b/public/app/features/actions/utils.test.ts @@ -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'); + }); +}); diff --git a/public/app/features/actions/utils.ts b/public/app/features/actions/utils.ts index e8771075178..8c78f5ce6a9 100644 --- a/public/app/features/actions/utils.ts +++ b/public/app/features/actions/utils.ts @@ -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 = { - 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 = {}; + const requestHeaders: Record = {}; - 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 */ diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 9264d6fd985..a94a46c122d 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -752,7 +752,7 @@ export class ElementState implements LayerElement { { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 612002cf70d..f80a38f77f9 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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" } },