mirror of https://github.com/grafana/grafana
Canvas: Add actions support (#90677)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>pull/93082/head^2
parent
b89f3f8115
commit
af48d3db1e
@ -0,0 +1,70 @@ |
||||
import { ScopedVars } from './ScopedVars'; |
||||
import { DataFrame, Field, ValueLinkConfig } from './dataFrame'; |
||||
import { InterpolateFunction } from './panel'; |
||||
import { SelectableValue } from './select'; |
||||
|
||||
export interface Action<T = ActionType.Fetch, TOptions = FetchOptions> { |
||||
type: T; |
||||
title: string; |
||||
options: TOptions; |
||||
} |
||||
|
||||
/** |
||||
* Processed Action Model. The values are ready to use |
||||
*/ |
||||
export interface ActionModel<T = any> { |
||||
title: string; |
||||
onClick: (event: any, origin?: any) => void; |
||||
} |
||||
|
||||
interface FetchOptions { |
||||
method: HttpRequestMethod; |
||||
url: string; |
||||
body?: string; |
||||
queryParams?: Array<[string, string]>; |
||||
headers?: Array<[string, string]>; |
||||
} |
||||
|
||||
export enum ActionType { |
||||
Fetch = 'fetch', |
||||
} |
||||
|
||||
export enum HttpRequestMethod { |
||||
POST = 'POST', |
||||
PUT = 'PUT', |
||||
GET = 'GET', |
||||
} |
||||
|
||||
export const httpMethodOptions: SelectableValue[] = [ |
||||
{ label: HttpRequestMethod.POST, value: HttpRequestMethod.POST }, |
||||
{ label: HttpRequestMethod.PUT, value: HttpRequestMethod.PUT }, |
||||
{ label: HttpRequestMethod.GET, value: HttpRequestMethod.GET }, |
||||
]; |
||||
|
||||
export const contentTypeOptions: SelectableValue[] = [ |
||||
{ label: 'application/json', value: 'application/json' }, |
||||
{ label: 'text/plain', value: 'text/plain' }, |
||||
{ label: 'application/xml', value: 'application/xml' }, |
||||
{ label: 'application/x-www-form-urlencoded', value: 'application/x-www-form-urlencoded' }, |
||||
]; |
||||
|
||||
export const defaultActionConfig: Action = { |
||||
type: ActionType.Fetch, |
||||
title: '', |
||||
options: { |
||||
url: '', |
||||
method: HttpRequestMethod.POST, |
||||
body: '{}', |
||||
queryParams: [], |
||||
headers: [['Content-Type', 'application/json']], |
||||
}, |
||||
}; |
||||
|
||||
export type ActionsArgs = { |
||||
frame: DataFrame; |
||||
field: Field; |
||||
fieldScopedVars: ScopedVars; |
||||
replaceVariables: InterpolateFunction; |
||||
actions: Action[]; |
||||
config: ValueLinkConfig; |
||||
}; |
@ -0,0 +1,18 @@ |
||||
import { ActionModel, Field } from '@grafana/data'; |
||||
|
||||
import { Button, ButtonProps } from '../Button'; |
||||
|
||||
type ActionButtonProps = ButtonProps & { |
||||
action: ActionModel<Field>; |
||||
}; |
||||
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export function ActionButton({ action, ...buttonProps }: ActionButtonProps) { |
||||
return ( |
||||
<Button variant="primary" size="sm" onClick={action.onClick} {...buttonProps}> |
||||
{action.title} |
||||
</Button> |
||||
); |
||||
} |
|
@ -0,0 +1,185 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { memo } from 'react'; |
||||
|
||||
import { Action, GrafanaTheme2, httpMethodOptions, HttpRequestMethod, VariableSuggestion } from '@grafana/data'; |
||||
import { Field } from '@grafana/ui/src/components/Forms/Field'; |
||||
import { InlineField } from '@grafana/ui/src/components/Forms/InlineField'; |
||||
import { InlineFieldRow } from '@grafana/ui/src/components/Forms/InlineFieldRow'; |
||||
import { RadioButtonGroup } from '@grafana/ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup'; |
||||
import { JSONFormatter } from '@grafana/ui/src/components/JSONFormatter/JSONFormatter'; |
||||
import { useStyles2 } from '@grafana/ui/src/themes'; |
||||
|
||||
import { HTMLElementType, SuggestionsInput } from '../transformers/suggestionsInput/SuggestionsInput'; |
||||
|
||||
import { ParamsEditor } from './ParamsEditor'; |
||||
|
||||
interface ActionEditorProps { |
||||
index: number; |
||||
value: Action; |
||||
onChange: (index: number, action: Action) => void; |
||||
suggestions: VariableSuggestion[]; |
||||
} |
||||
|
||||
const LABEL_WIDTH = 13; |
||||
|
||||
export const ActionEditor = memo(({ index, value, onChange, suggestions }: ActionEditorProps) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const onTitleChange = (title: string) => { |
||||
onChange(index, { ...value, title }); |
||||
}; |
||||
|
||||
const onUrlChange = (url: string) => { |
||||
onChange(index, { |
||||
...value, |
||||
options: { |
||||
...value.options, |
||||
url, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
const onBodyChange = (body: string) => { |
||||
onChange(index, { |
||||
...value, |
||||
options: { |
||||
...value.options, |
||||
body, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
const onMethodChange = (method: HttpRequestMethod) => { |
||||
onChange(index, { |
||||
...value, |
||||
options: { |
||||
...value.options, |
||||
method, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
const onQueryParamsChange = (queryParams: Array<[string, string]>) => { |
||||
onChange(index, { |
||||
...value, |
||||
options: { |
||||
...value.options, |
||||
queryParams, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
const onHeadersChange = (headers: Array<[string, string]>) => { |
||||
onChange(index, { |
||||
...value, |
||||
options: { |
||||
...value.options, |
||||
headers, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
const renderJSON = (data = '{}') => { |
||||
try { |
||||
const json = JSON.parse(data); |
||||
return <JSONFormatter json={json} />; |
||||
} catch (error) { |
||||
if (error instanceof Error) { |
||||
return `Invalid JSON provided: ${error.message}`; |
||||
} else { |
||||
return 'Invalid JSON provided'; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
const shouldRenderJSON = |
||||
value.options.method !== HttpRequestMethod.GET && |
||||
value.options.headers?.some(([name, value]) => name === 'Content-Type' && value === 'application/json'); |
||||
|
||||
return ( |
||||
<div className={styles.listItem}> |
||||
<Field label="Title"> |
||||
<SuggestionsInput |
||||
value={value.title} |
||||
onChange={onTitleChange} |
||||
suggestions={suggestions} |
||||
autoFocus={value.title === ''} |
||||
placeholder="Action title" |
||||
/> |
||||
</Field> |
||||
|
||||
<InlineFieldRow> |
||||
<InlineField label="Method" labelWidth={LABEL_WIDTH} grow={true}> |
||||
<RadioButtonGroup<HttpRequestMethod> |
||||
value={value?.options.method} |
||||
options={httpMethodOptions} |
||||
onChange={onMethodChange} |
||||
fullWidth |
||||
/> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
|
||||
<InlineFieldRow> |
||||
<InlineField label="URL" labelWidth={LABEL_WIDTH} grow={true}> |
||||
<SuggestionsInput |
||||
value={value.options.url} |
||||
onChange={onUrlChange} |
||||
suggestions={suggestions} |
||||
placeholder="URL" |
||||
/> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
|
||||
<Field label="Query parameters" className={styles.fieldGap}> |
||||
<ParamsEditor |
||||
value={value?.options.queryParams ?? []} |
||||
onChange={onQueryParamsChange} |
||||
suggestions={suggestions} |
||||
/> |
||||
</Field> |
||||
|
||||
<Field label="Headers"> |
||||
<ParamsEditor |
||||
value={value?.options.headers ?? []} |
||||
onChange={onHeadersChange} |
||||
suggestions={suggestions} |
||||
contentTypeHeader={true} |
||||
/> |
||||
</Field> |
||||
|
||||
{value?.options.method !== HttpRequestMethod.GET && ( |
||||
<Field label="Body"> |
||||
<SuggestionsInput |
||||
value={value.options.body} |
||||
onChange={onBodyChange} |
||||
suggestions={suggestions} |
||||
type={HTMLElementType.TextAreaElement} |
||||
/> |
||||
</Field> |
||||
)} |
||||
|
||||
{shouldRenderJSON && ( |
||||
<> |
||||
<br /> |
||||
{renderJSON(value?.options.body)} |
||||
</> |
||||
)} |
||||
</div> |
||||
); |
||||
}); |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
listItem: css({ |
||||
marginBottom: theme.spacing(), |
||||
}), |
||||
infoText: css({ |
||||
paddingBottom: theme.spacing(2), |
||||
marginLeft: '66px', |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
fieldGap: css({ |
||||
marginTop: theme.spacing(2), |
||||
}), |
||||
}); |
||||
|
||||
ActionEditor.displayName = 'ActionEditor'; |
@ -0,0 +1,52 @@ |
||||
import { useState } from 'react'; |
||||
|
||||
import { Action, DataFrame, VariableSuggestion } from '@grafana/data'; |
||||
import { Button } from '@grafana/ui/src/components/Button'; |
||||
import { Modal } from '@grafana/ui/src/components/Modal/Modal'; |
||||
|
||||
import { ActionEditor } from './ActionEditor'; |
||||
|
||||
interface ActionEditorModalContentProps { |
||||
action: Action; |
||||
index: number; |
||||
data: DataFrame[]; |
||||
onSave: (index: number, action: Action) => void; |
||||
onCancel: (index: number) => void; |
||||
getSuggestions: () => VariableSuggestion[]; |
||||
} |
||||
|
||||
export const ActionEditorModalContent = ({ |
||||
action, |
||||
index, |
||||
onSave, |
||||
onCancel, |
||||
getSuggestions, |
||||
}: ActionEditorModalContentProps) => { |
||||
const [dirtyAction, setDirtyAction] = useState(action); |
||||
|
||||
return ( |
||||
<> |
||||
<ActionEditor |
||||
value={dirtyAction} |
||||
index={index} |
||||
onChange={(index, action) => { |
||||
setDirtyAction(action); |
||||
}} |
||||
suggestions={getSuggestions()} |
||||
/> |
||||
<Modal.ButtonRow> |
||||
<Button variant="secondary" onClick={() => onCancel(index)} fill="outline"> |
||||
Cancel |
||||
</Button> |
||||
<Button |
||||
onClick={() => { |
||||
onSave(index, dirtyAction); |
||||
}} |
||||
disabled={dirtyAction.title.trim() === '' || dirtyAction.options.url.trim() === ''} |
||||
> |
||||
Save |
||||
</Button> |
||||
</Modal.ButtonRow> |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,192 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd'; |
||||
import { cloneDeep } from 'lodash'; |
||||
import { ReactNode, useEffect, useState } from 'react'; |
||||
|
||||
import { Action, DataFrame, GrafanaTheme2, defaultActionConfig, VariableSuggestion } from '@grafana/data'; |
||||
import { Button } from '@grafana/ui/src/components/Button'; |
||||
import { Modal } from '@grafana/ui/src/components/Modal/Modal'; |
||||
import { useStyles2 } from '@grafana/ui/src/themes'; |
||||
|
||||
import { ActionEditorModalContent } from './ActionEditorModalContent'; |
||||
import { ActionListItem } from './ActionsListItem'; |
||||
|
||||
interface ActionsInlineEditorProps { |
||||
actions?: Action[]; |
||||
onChange: (actions: Action[]) => void; |
||||
data: DataFrame[]; |
||||
getSuggestions: () => VariableSuggestion[]; |
||||
showOneClick?: boolean; |
||||
} |
||||
|
||||
export const ActionsInlineEditor = ({ |
||||
actions, |
||||
onChange, |
||||
data, |
||||
getSuggestions, |
||||
showOneClick = false, |
||||
}: ActionsInlineEditorProps) => { |
||||
const [editIndex, setEditIndex] = useState<number | null>(null); |
||||
const [isNew, setIsNew] = useState(false); |
||||
|
||||
const [actionsSafe, setActionsSafe] = useState<Action[]>([]); |
||||
|
||||
useEffect(() => { |
||||
setActionsSafe(actions ?? []); |
||||
}, [actions]); |
||||
|
||||
const styles = useStyles2(getActionsInlineEditorStyle); |
||||
const isEditing = editIndex !== null; |
||||
|
||||
const onActionChange = (index: number, action: Action) => { |
||||
if (isNew) { |
||||
if (action.title.trim() === '') { |
||||
setIsNew(false); |
||||
setEditIndex(null); |
||||
return; |
||||
} else { |
||||
setEditIndex(null); |
||||
setIsNew(false); |
||||
} |
||||
} |
||||
const update = cloneDeep(actionsSafe); |
||||
update[index] = action; |
||||
onChange(update); |
||||
|
||||
setEditIndex(null); |
||||
}; |
||||
|
||||
const onActionAdd = () => { |
||||
let update = cloneDeep(actionsSafe); |
||||
setEditIndex(update.length); |
||||
setIsNew(true); |
||||
}; |
||||
|
||||
const onActionCancel = (index: number) => { |
||||
if (isNew) { |
||||
setIsNew(false); |
||||
} |
||||
setEditIndex(null); |
||||
}; |
||||
|
||||
const onActionRemove = (index: number) => { |
||||
const update = cloneDeep(actionsSafe); |
||||
update.splice(index, 1); |
||||
onChange(update); |
||||
}; |
||||
|
||||
const onDragEnd = (result: DropResult) => { |
||||
if (!actions || !result.destination) { |
||||
return; |
||||
} |
||||
|
||||
const update = cloneDeep(actionsSafe); |
||||
const action = update[result.source.index]; |
||||
|
||||
update.splice(result.source.index, 1); |
||||
update.splice(result.destination.index, 0, action); |
||||
|
||||
setActionsSafe(update); |
||||
onChange(update); |
||||
}; |
||||
|
||||
const renderFirstAction = (actionsJSX: ReactNode, key: string) => { |
||||
if (showOneClick) { |
||||
return ( |
||||
<div className={styles.oneClickOverlay} key={key}> |
||||
<span className={styles.oneClickSpan}>One-click action</span> {actionsJSX} |
||||
</div> |
||||
); |
||||
} |
||||
return actionsJSX; |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
<DragDropContext onDragEnd={onDragEnd}> |
||||
<Droppable droppableId="sortable-actions" direction="vertical"> |
||||
{(provided) => ( |
||||
<div className={styles.wrapper} ref={provided.innerRef} {...provided.droppableProps}> |
||||
{actionsSafe.map((action, idx) => { |
||||
const key = `${action.title}/${idx}`; |
||||
|
||||
const actionsJSX = ( |
||||
<div className={styles.itemWrapper} key={key}> |
||||
<ActionListItem |
||||
key={key} |
||||
index={idx} |
||||
action={action} |
||||
onChange={onActionChange} |
||||
onEdit={() => setEditIndex(idx)} |
||||
onRemove={() => onActionRemove(idx)} |
||||
data={data} |
||||
itemKey={key} |
||||
/> |
||||
</div> |
||||
); |
||||
|
||||
if (idx === 0) { |
||||
return renderFirstAction(actionsJSX, key); |
||||
} |
||||
|
||||
return actionsJSX; |
||||
})} |
||||
{provided.placeholder} |
||||
</div> |
||||
)} |
||||
</Droppable> |
||||
</DragDropContext> |
||||
|
||||
{isEditing && editIndex !== null && ( |
||||
<Modal |
||||
title="Edit action" |
||||
isOpen={true} |
||||
closeOnBackdropClick={false} |
||||
onDismiss={() => { |
||||
onActionCancel(editIndex); |
||||
}} |
||||
> |
||||
<ActionEditorModalContent |
||||
index={editIndex} |
||||
action={isNew ? defaultActionConfig : actionsSafe[editIndex]} |
||||
data={data} |
||||
onSave={onActionChange} |
||||
onCancel={onActionCancel} |
||||
getSuggestions={getSuggestions} |
||||
/> |
||||
</Modal> |
||||
)} |
||||
|
||||
<Button size="sm" icon="plus" onClick={onActionAdd} variant="secondary" className={styles.button}> |
||||
Add action |
||||
</Button> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
const getActionsInlineEditorStyle = (theme: GrafanaTheme2) => ({ |
||||
wrapper: css({ |
||||
marginBottom: theme.spacing(2), |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
}), |
||||
oneClickOverlay: css({ |
||||
height: 'auto', |
||||
border: `2px dashed ${theme.colors.text.link}`, |
||||
fontSize: 10, |
||||
color: theme.colors.text.primary, |
||||
marginBottom: theme.spacing(1), |
||||
}), |
||||
oneClickSpan: css({ |
||||
padding: 10, |
||||
// Negates the padding on the span from moving the underlying link
|
||||
marginBottom: -10, |
||||
display: 'inline-block', |
||||
}), |
||||
itemWrapper: css({ |
||||
padding: '4px 8px 8px 8px', |
||||
}), |
||||
button: css({ |
||||
marginLeft: theme.spacing(1), |
||||
}), |
||||
}); |
@ -0,0 +1,111 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import { Draggable } from '@hello-pangea/dnd'; |
||||
|
||||
import { Action, DataFrame, GrafanaTheme2 } from '@grafana/data'; |
||||
import { Icon } from '@grafana/ui/src/components/Icon/Icon'; |
||||
import { IconButton } from '@grafana/ui/src/components/IconButton/IconButton'; |
||||
import { useStyles2 } from '@grafana/ui/src/themes'; |
||||
|
||||
export interface ActionsListItemProps { |
||||
index: number; |
||||
action: Action; |
||||
data: DataFrame[]; |
||||
onChange: (index: number, action: Action) => void; |
||||
onEdit: () => void; |
||||
onRemove: () => void; |
||||
isEditing?: boolean; |
||||
itemKey: string; |
||||
} |
||||
|
||||
export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: ActionsListItemProps) => { |
||||
const styles = useStyles2(getActionListItemStyles); |
||||
const { title = '' } = action; |
||||
|
||||
const hasTitle = title.trim() !== ''; |
||||
|
||||
return ( |
||||
<Draggable key={itemKey} draggableId={itemKey} index={index}> |
||||
{(provided) => ( |
||||
<> |
||||
<div |
||||
className={cx(styles.wrapper, styles.dragRow)} |
||||
ref={provided.innerRef} |
||||
{...provided.draggableProps} |
||||
key={index} |
||||
> |
||||
<div className={styles.linkDetails}> |
||||
<div className={cx(styles.url, !hasTitle && styles.notConfigured)}> |
||||
{hasTitle ? title : 'Action title not provided'} |
||||
</div> |
||||
</div> |
||||
<div className={styles.icons}> |
||||
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit action title" /> |
||||
<IconButton name="times" onClick={onRemove} className={styles.icon} tooltip="Remove action title" /> |
||||
<div className={styles.dragIcon} {...provided.dragHandleProps}> |
||||
<Icon name="draggabledots" size="lg" /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</> |
||||
)} |
||||
</Draggable> |
||||
); |
||||
}; |
||||
|
||||
const getActionListItemStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
wrapper: css({ |
||||
display: 'flex', |
||||
flexGrow: 1, |
||||
alignItems: 'center', |
||||
justifyContent: 'space-between', |
||||
width: '100%', |
||||
padding: '5px 0 5px 10px', |
||||
borderRadius: theme.shape.radius.default, |
||||
background: theme.colors.background.secondary, |
||||
gap: 8, |
||||
}), |
||||
linkDetails: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
flexGrow: 1, |
||||
}), |
||||
errored: css({ |
||||
color: theme.colors.error.text, |
||||
fontStyle: 'italic', |
||||
}), |
||||
notConfigured: css({ |
||||
fontStyle: 'italic', |
||||
}), |
||||
title: css({ |
||||
color: theme.colors.text.primary, |
||||
fontSize: theme.typography.size.sm, |
||||
fontWeight: theme.typography.fontWeightMedium, |
||||
}), |
||||
url: css({ |
||||
color: theme.colors.text.secondary, |
||||
fontSize: theme.typography.size.sm, |
||||
whiteSpace: 'nowrap', |
||||
overflow: 'hidden', |
||||
textOverflow: 'ellipsis', |
||||
maxWidth: `calc(100% - 100px)`, |
||||
}), |
||||
dragIcon: css({ |
||||
cursor: 'grab', |
||||
color: theme.colors.text.secondary, |
||||
margin: theme.spacing(0, 0.5), |
||||
}), |
||||
icon: css({ |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
dragRow: css({ |
||||
position: 'relative', |
||||
}), |
||||
icons: css({ |
||||
display: 'flex', |
||||
padding: 6, |
||||
alignItems: 'center', |
||||
gap: 8, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,130 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { useEffect, useState } from 'react'; |
||||
|
||||
import { contentTypeOptions, GrafanaTheme2, VariableSuggestion } from '@grafana/data'; |
||||
import { IconButton } from '@grafana/ui/src/components/IconButton/IconButton'; |
||||
import { Input } from '@grafana/ui/src/components/Input/Input'; |
||||
import { Stack } from '@grafana/ui/src/components/Layout/Stack/Stack'; |
||||
import { Select } from '@grafana/ui/src/components/Select/Select'; |
||||
import { useStyles2 } from '@grafana/ui/src/themes'; |
||||
|
||||
import { SuggestionsInput } from '../transformers/suggestionsInput/SuggestionsInput'; |
||||
|
||||
interface Props { |
||||
onChange: (v: Array<[string, string]>) => void; |
||||
value: Array<[string, string]>; |
||||
suggestions: VariableSuggestion[]; |
||||
contentTypeHeader?: boolean; |
||||
} |
||||
|
||||
export const ParamsEditor = ({ value, onChange, suggestions, contentTypeHeader = false }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const headersContentType = value.find(([key, value]) => key === 'Content-Type'); |
||||
|
||||
const [paramName, setParamName] = useState(''); |
||||
const [paramValue, setParamValue] = useState(''); |
||||
const [contentTypeParamValue, setContentTypeParamValue] = useState(''); |
||||
|
||||
useEffect(() => { |
||||
if (contentTypeParamValue !== '') { |
||||
setContentTypeParamValue(contentTypeParamValue); |
||||
} else if (headersContentType) { |
||||
setContentTypeParamValue(headersContentType[1]); |
||||
} |
||||
}, [contentTypeParamValue, headersContentType]); |
||||
|
||||
// forces re-init of first SuggestionsInput(s), since they are stateful and don't respond to 'value' prop changes to be able to clear them :(
|
||||
const [entryKey, setEntryKey] = useState(Math.random().toString()); |
||||
|
||||
const changeParamValue = (paramValue: string) => { |
||||
setParamValue(paramValue); |
||||
}; |
||||
|
||||
const changeParamName = (paramName: string) => { |
||||
setParamName(paramName); |
||||
}; |
||||
|
||||
const removeParam = (key: string) => () => { |
||||
const updatedParams = value.filter((param) => param[0] !== key); |
||||
onChange(updatedParams); |
||||
}; |
||||
|
||||
const addParam = (contentType?: [string, string]) => { |
||||
let newParams: Array<[string, string]>; |
||||
|
||||
if (value) { |
||||
newParams = value.filter((e) => e[0] !== (contentType ? contentType[0] : paramName)); |
||||
} else { |
||||
newParams = []; |
||||
} |
||||
|
||||
newParams.push(contentType ?? [paramName, paramValue]); |
||||
newParams.sort((a, b) => a[0].localeCompare(b[0])); |
||||
onChange(newParams); |
||||
|
||||
setParamName(''); |
||||
setParamValue(''); |
||||
setEntryKey(Math.random().toString()); |
||||
}; |
||||
|
||||
const changeContentTypeParamValue = (value: string) => { |
||||
setContentTypeParamValue(value); |
||||
addParam(['Content-Type', value]); |
||||
}; |
||||
|
||||
const isAddParamsDisabled = paramName === '' || paramValue === ''; |
||||
|
||||
return ( |
||||
<div> |
||||
<Stack direction="row" key={entryKey}> |
||||
<SuggestionsInput |
||||
value={paramName} |
||||
onChange={changeParamName} |
||||
suggestions={suggestions} |
||||
placeholder="Key" |
||||
style={{ width: 332 }} |
||||
/> |
||||
<SuggestionsInput |
||||
value={paramValue} |
||||
onChange={changeParamValue} |
||||
suggestions={suggestions} |
||||
placeholder="Value" |
||||
style={{ width: 332 }} |
||||
/> |
||||
<IconButton aria-label="add" name="plus-circle" onClick={() => addParam()} disabled={isAddParamsDisabled} /> |
||||
</Stack> |
||||
|
||||
<Stack direction="column"> |
||||
{Array.from(value.filter((param) => param[0] !== 'Content-Type') || []).map((entry) => ( |
||||
<Stack key={entry[0]} direction="row"> |
||||
<Input disabled value={entry[0]} /> |
||||
<Input disabled value={entry[1]} /> |
||||
<IconButton aria-label="delete" onClick={removeParam(entry[0])} name="trash-alt" /> |
||||
</Stack> |
||||
))} |
||||
</Stack> |
||||
|
||||
{contentTypeHeader && ( |
||||
<div className={styles.extraHeader}> |
||||
<Stack direction="row"> |
||||
<Input value={'Content-Type'} disabled /> |
||||
<Select |
||||
onChange={(select) => changeContentTypeParamValue(select.value as string)} |
||||
options={contentTypeOptions} |
||||
value={contentTypeParamValue} |
||||
/> |
||||
</Stack> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
extraHeader: css({ |
||||
marginTop: theme.spacing(1), |
||||
marginBottom: theme.spacing(1), |
||||
maxWidth: 673, |
||||
}), |
||||
}); |
@ -0,0 +1,148 @@ |
||||
import { |
||||
Action, |
||||
ActionModel, |
||||
AppEvents, |
||||
DataContextScopedVar, |
||||
DataFrame, |
||||
DataLink, |
||||
Field, |
||||
FieldType, |
||||
getFieldDataContextClone, |
||||
InterpolateFunction, |
||||
ScopedVars, |
||||
textUtil, |
||||
ValueLinkConfig, |
||||
} from '@grafana/data'; |
||||
import { BackendSrvRequest, 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 getActions = ( |
||||
frame: DataFrame, |
||||
field: Field, |
||||
fieldScopedVars: ScopedVars, |
||||
replaceVariables: InterpolateFunction, |
||||
actions: Action[], |
||||
config: ValueLinkConfig |
||||
): Array<ActionModel<Field>> => { |
||||
if (!actions || actions.length === 0) { |
||||
return []; |
||||
} |
||||
|
||||
const actionModels = actions.map((action: Action) => { |
||||
const dataContext: DataContextScopedVar = getFieldDataContextClone(frame, field, fieldScopedVars); |
||||
const actionScopedVars = { |
||||
...fieldScopedVars, |
||||
__dataContext: dataContext, |
||||
}; |
||||
|
||||
const boundReplaceVariables: InterpolateFunction = (value, scopedVars, format) => { |
||||
return replaceVariables(value, { ...actionScopedVars, ...scopedVars }, format); |
||||
}; |
||||
|
||||
// We are not displaying reduction result
|
||||
if (config.valueRowIndex !== undefined && !isNaN(config.valueRowIndex)) { |
||||
dataContext.value.rowIndex = config.valueRowIndex; |
||||
} else { |
||||
dataContext.value.calculatedValue = config.calculatedValue; |
||||
} |
||||
|
||||
let actionModel: ActionModel<Field> = { title: '', onClick: (e) => {} }; |
||||
|
||||
actionModel = { |
||||
title: replaceVariables(action.title || '', actionScopedVars), |
||||
onClick: (evt: MouseEvent, origin: Field) => { |
||||
buildActionOnClick(action, boundReplaceVariables); |
||||
}, |
||||
}; |
||||
|
||||
return actionModel; |
||||
}); |
||||
|
||||
return actionModels.filter((action): action is ActionModel => !!action); |
||||
}; |
||||
|
||||
/** @internal */ |
||||
const buildActionOnClick = (action: Action, replaceVariables: InterpolateFunction) => { |
||||
try { |
||||
const url = new URL(getUrl(replaceVariables(action.options.url))); |
||||
|
||||
const requestHeaders: Record<string, string> = {}; |
||||
|
||||
let request: BackendSrvRequest = { |
||||
url: url.toString(), |
||||
method: action.options.method, |
||||
data: getData(action, replaceVariables), |
||||
headers: requestHeaders, |
||||
}; |
||||
|
||||
if (action.options.headers) { |
||||
action.options.headers.forEach(([name, value]) => { |
||||
requestHeaders[replaceVariables(name)] = replaceVariables(value); |
||||
}); |
||||
} |
||||
|
||||
if (action.options.queryParams) { |
||||
action.options.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; |
||||
} |
||||
}; |
||||
|
||||
/** @internal */ |
||||
// @TODO update return type
|
||||
export const getActionsDefaultField = (dataLinks: DataLink[] = [], actions: Action[] = []) => { |
||||
return { |
||||
name: 'Default field', |
||||
type: FieldType.string, |
||||
config: { links: dataLinks, actions: actions }, |
||||
values: [], |
||||
}; |
||||
}; |
||||
|
||||
/** @internal */ |
||||
const getUrl = (endpoint: string) => { |
||||
const isRelativeUrl = endpoint.startsWith('/'); |
||||
if (isRelativeUrl) { |
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const sanitizedRelativeURL = textUtil.sanitizeUrl(endpoint) as RelativeUrl; |
||||
endpoint = createAbsoluteUrl(sanitizedRelativeURL, []); |
||||
} |
||||
|
||||
return endpoint; |
||||
}; |
||||
|
||||
/** @internal */ |
||||
const getData = (action: Action, replaceVariables: InterpolateFunction) => { |
||||
let data: string | undefined = action.options.body ? replaceVariables(action.options.body) : '{}'; |
||||
if (action.options.method === HttpRequestMethod.GET) { |
||||
data = undefined; |
||||
} |
||||
|
||||
return data; |
||||
}; |
@ -0,0 +1,20 @@ |
||||
import { StandardEditorProps, OneClickMode, Action, VariableSuggestionsScope } from '@grafana/data'; |
||||
import { CanvasElementOptions } from 'app/features/canvas/element'; |
||||
|
||||
import { ActionsInlineEditor } from '../../../../../features/actions/ActionsInlineEditor'; |
||||
|
||||
type Props = StandardEditorProps<Action[], CanvasElementOptions>; |
||||
|
||||
export function ActionsEditor({ value, onChange, item, context }: Props) { |
||||
const oneClickMode = item.settings?.oneClickMode; |
||||
|
||||
return ( |
||||
<ActionsInlineEditor |
||||
actions={value} |
||||
onChange={onChange} |
||||
getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])} |
||||
data={[]} |
||||
showOneClick={oneClickMode === OneClickMode.Action} |
||||
/> |
||||
); |
||||
} |
Loading…
Reference in new issue