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