mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Canvas: Add actions support (#90677)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
@@ -196,6 +196,11 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"packages/grafana-data/src/types/action.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"packages/grafana-data/src/types/annotations.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
@@ -1372,6 +1377,17 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/features/actions/ActionEditorModalContent.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/actions/ActionsInlineEditor.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/actions/ParamsEditor.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/admin/AdminEditOrgPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
@@ -5598,6 +5614,12 @@ exports[`better eslint`] = {
|
||||
"public/app/features/transformers/standardTransformers.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/transformers/suggestionsInput/SuggestionsInput.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"]
|
||||
],
|
||||
"public/app/features/users/TokenRevokedModal.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
|
||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -413,6 +413,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
|
||||
# Temp owners until Enterprise team takes over
|
||||
/public/app/features/migrate-to-cloud @grafana/grafana-frontend-platform
|
||||
|
||||
/public/app/features/actions/ @grafana/dataviz-squad
|
||||
/public/app/features/auth-config/ @grafana/identity-squad
|
||||
/public/app/features/annotations/ @grafana/dashboards-squad
|
||||
/public/app/features/api-keys/ @grafana/identity-squad
|
||||
|
||||
@@ -119,6 +119,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `lokiExperimentalStreaming` | Support new streaming approach for loki (prototype, needs special loki build) |
|
||||
| `storage` | Configurable storage for dashboards, datasources, and resources |
|
||||
| `canvasPanelNesting` | Allow elements nesting |
|
||||
| `vizActions` | Allow actions in visualizations |
|
||||
| `disableSecretsCompatibility` | Disable duplicated secret storage in legacy tables |
|
||||
| `logRequestsInstrumentedAsUnknown` | Logs the path for requests that are instrumented as unknown |
|
||||
| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema |
|
||||
|
||||
@@ -607,7 +607,7 @@ export function useFieldOverrides(
|
||||
/**
|
||||
* Clones the existing dataContext or creates a new one
|
||||
*/
|
||||
function getFieldDataContextClone(frame: DataFrame, field: Field, fieldScopedVars: ScopedVars) {
|
||||
export function getFieldDataContextClone(frame: DataFrame, field: Field, fieldScopedVars: ScopedVars) {
|
||||
if (fieldScopedVars?.__dataContext) {
|
||||
return {
|
||||
value: {
|
||||
|
||||
@@ -145,6 +145,7 @@ export {
|
||||
validateFieldConfig,
|
||||
applyRawFieldOverrides,
|
||||
useFieldOverrides,
|
||||
getFieldDataContextClone,
|
||||
} from './field/fieldOverrides';
|
||||
export { getFieldDisplayValuesProxy } from './field/getFieldDisplayValuesProxy';
|
||||
export {
|
||||
@@ -800,6 +801,14 @@ export {
|
||||
VariableSuggestionsScope,
|
||||
OneClickMode,
|
||||
} from './types/dataLink';
|
||||
export {
|
||||
type Action,
|
||||
type ActionModel,
|
||||
HttpRequestMethod,
|
||||
defaultActionConfig,
|
||||
contentTypeOptions,
|
||||
httpMethodOptions,
|
||||
} from './types/action';
|
||||
export { DataFrameType } from './types/dataFrameTypes';
|
||||
export {
|
||||
FieldType,
|
||||
|
||||
70
packages/grafana-data/src/types/action.ts
Normal file
70
packages/grafana-data/src/types/action.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -51,7 +51,6 @@ export interface DataLink<T extends DataQuery = any> {
|
||||
internal?: InternalDataLink<T>;
|
||||
|
||||
origin?: DataLinkConfigOrigin;
|
||||
sortIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,6 +130,7 @@ export enum VariableSuggestionsScope {
|
||||
}
|
||||
|
||||
export enum OneClickMode {
|
||||
Action = 'action',
|
||||
Link = 'link',
|
||||
Off = 'off',
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface FeatureToggles {
|
||||
autoMigrateXYChartPanel?: boolean;
|
||||
disableAngular?: boolean;
|
||||
canvasPanelNesting?: boolean;
|
||||
vizActions?: boolean;
|
||||
disableSecretsCompatibility?: boolean;
|
||||
logRequestsInstrumentedAsUnknown?: boolean;
|
||||
topnav?: boolean;
|
||||
|
||||
18
packages/grafana-ui/src/components/Actions/ActionButton.tsx
Normal file
18
packages/grafana-ui/src/components/Actions/ActionButton.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { Field, GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
import { ActionModel, Field, GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
|
||||
import { Button, ButtonProps, DataLinkButton, Stack } from '..';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { ActionButton } from '../Actions/ActionButton';
|
||||
|
||||
interface VizTooltipFooterProps {
|
||||
dataLinks: Array<LinkModel<Field>>;
|
||||
actions?: Array<ActionModel<Field>>;
|
||||
annotate?: () => void;
|
||||
}
|
||||
|
||||
export const ADD_ANNOTATION_ID = 'add-annotation-button';
|
||||
|
||||
export const VizTooltipFooter = ({ dataLinks, annotate }: VizTooltipFooterProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const renderDataLinks = () => {
|
||||
const renderDataLinks = (dataLinks: LinkModel[]) => {
|
||||
const buttonProps: ButtonProps = {
|
||||
variant: 'secondary',
|
||||
};
|
||||
@@ -29,9 +28,23 @@ export const VizTooltipFooter = ({ dataLinks, annotate }: VizTooltipFooterProps)
|
||||
);
|
||||
};
|
||||
|
||||
const renderActions = (actions: ActionModel[]) => {
|
||||
return (
|
||||
<Stack direction="column" justifyContent="flex-start">
|
||||
{actions.map((action, i) => (
|
||||
<ActionButton key={i} action={action} variant="secondary" />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const VizTooltipFooter = ({ dataLinks, actions, annotate }: VizTooltipFooterProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{dataLinks.length > 0 && <div className={styles.dataLinks}>{renderDataLinks()}</div>}
|
||||
{dataLinks.length > 0 && <div className={styles.dataLinks}>{renderDataLinks(dataLinks)}</div>}
|
||||
{actions && actions.length > 0 && <div className={styles.dataLinks}>{renderActions(actions)}</div>}
|
||||
{annotate != null && (
|
||||
<div className={styles.addAnnotations}>
|
||||
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
|
||||
|
||||
@@ -165,6 +165,14 @@ var (
|
||||
Owner: grafanaDatavizSquad,
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
{
|
||||
Name: "vizActions",
|
||||
Description: "Allow actions in visualizations",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDatavizSquad,
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
{
|
||||
Name: "disableSecretsCompatibility",
|
||||
Description: "Disable duplicated secret storage in legacy tables",
|
||||
|
||||
@@ -19,6 +19,7 @@ autoMigrateStatPanel,preview,@grafana/dataviz-squad,false,false,true
|
||||
autoMigrateXYChartPanel,GA,@grafana/dataviz-squad,false,false,true
|
||||
disableAngular,preview,@grafana/dataviz-squad,false,false,true
|
||||
canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true
|
||||
vizActions,experimental,@grafana/dataviz-squad,false,false,true
|
||||
disableSecretsCompatibility,experimental,@grafana/hosted-grafana-team,false,true,false
|
||||
logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,false,false,false
|
||||
topnav,deprecated,@grafana/grafana-frontend-platform,false,false,false
|
||||
|
||||
|
@@ -87,6 +87,10 @@ const (
|
||||
// Allow elements nesting
|
||||
FlagCanvasPanelNesting = "canvasPanelNesting"
|
||||
|
||||
// FlagVizActions
|
||||
// Allow actions in visualizations
|
||||
FlagVizActions = "vizActions"
|
||||
|
||||
// FlagDisableSecretsCompatibility
|
||||
// Disable duplicated secret storage in legacy tables
|
||||
FlagDisableSecretsCompatibility = "disableSecretsCompatibility"
|
||||
|
||||
@@ -2831,6 +2831,20 @@
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "vizActions",
|
||||
"resourceVersion": "1722461779830",
|
||||
"creationTimestamp": "2024-07-31T21:36:19Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Allow actions in visualizations",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dataviz-squad",
|
||||
"frontend": true,
|
||||
"hideFromAdminPage": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "vizAndWidgetSplit",
|
||||
|
||||
185
public/app/features/actions/ActionEditor.tsx
Normal file
185
public/app/features/actions/ActionEditor.tsx
Normal file
@@ -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';
|
||||
52
public/app/features/actions/ActionEditorModalContent.tsx
Normal file
52
public/app/features/actions/ActionEditorModalContent.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
192
public/app/features/actions/ActionsInlineEditor.tsx
Normal file
192
public/app/features/actions/ActionsInlineEditor.tsx
Normal file
@@ -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),
|
||||
}),
|
||||
});
|
||||
111
public/app/features/actions/ActionsListItem.tsx
Normal file
111
public/app/features/actions/ActionsListItem.tsx
Normal file
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
130
public/app/features/actions/ParamsEditor.tsx
Normal file
130
public/app/features/actions/ParamsEditor.tsx
Normal file
@@ -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,
|
||||
}),
|
||||
});
|
||||
148
public/app/features/actions/utils.ts
Normal file
148
public/app/features/actions/utils.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
import { DataLink, RegistryItem, OneClickMode } from '@grafana/data';
|
||||
import { DataLink, RegistryItem, OneClickMode, Action } from '@grafana/data';
|
||||
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
|
||||
import { ColorDimensionConfig, ScaleDimensionConfig } from '@grafana/schema';
|
||||
import { config } from 'app/core/config';
|
||||
@@ -33,6 +33,7 @@ export interface CanvasElementOptions<TConfig = any> {
|
||||
border?: LineConfig;
|
||||
connections?: CanvasConnection[];
|
||||
links?: DataLink[];
|
||||
actions?: Action[];
|
||||
oneClickMode?: OneClickMode;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { CSSProperties } from 'react';
|
||||
import { OnDrag, OnResize, OnRotate } from 'react-moveable/declaration/types';
|
||||
|
||||
import { FieldType, getLinksSupplier, LinkModel, OneClickMode, ValueLinkConfig } from '@grafana/data';
|
||||
import { FieldType, getLinksSupplier, LinkModel, OneClickMode, ScopedVars, ValueLinkConfig } from '@grafana/data';
|
||||
import { LayerElement } from 'app/core/components/Layers/types';
|
||||
import { notFoundItem } from 'app/features/canvas/elements/notFound';
|
||||
import { DimensionContext } from 'app/features/dimensions';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'app/plugins/panel/canvas/panelcfg.gen';
|
||||
import { getConnectionsByTarget, getRowIndex, isConnectionTarget } from 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import { getActions, getActionsDefaultField } from '../../actions/utils';
|
||||
import { CanvasElementItem, CanvasElementOptions } from '../element';
|
||||
import { canvasElementRegistry } from '../registry';
|
||||
|
||||
@@ -379,7 +380,7 @@ export class ElementState implements LayerElement {
|
||||
const defaultField = {
|
||||
name: 'Default field',
|
||||
type: FieldType.string,
|
||||
config: { links: this.options.links ?? [] },
|
||||
config: { links: this.options.links ?? [], actions: this.options.actions ?? [] },
|
||||
values: [],
|
||||
};
|
||||
|
||||
@@ -597,12 +598,22 @@ export class ElementState implements LayerElement {
|
||||
|
||||
const shouldHandleOneClickLink =
|
||||
this.options.oneClickMode === OneClickMode.Link && this.options.links && this.options.links.length > 0;
|
||||
|
||||
const shouldHandleOneClickAction =
|
||||
this.options.oneClickMode === OneClickMode.Action && this.options.actions && this.options.actions.length > 0;
|
||||
|
||||
if (shouldHandleOneClickLink && this.div) {
|
||||
const primaryDataLink = this.getPrimaryDataLink();
|
||||
if (primaryDataLink) {
|
||||
this.div.style.cursor = 'pointer';
|
||||
this.div.title = `Navigate to ${primaryDataLink.title === '' ? 'data link' : primaryDataLink.title}`;
|
||||
}
|
||||
} else if (shouldHandleOneClickAction && this.div) {
|
||||
const primaryAction = this.getPrimaryAction();
|
||||
if (primaryAction) {
|
||||
this.div.style.cursor = 'pointer';
|
||||
this.div.title = primaryAction.title;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -615,6 +626,38 @@ export class ElementState implements LayerElement {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
getPrimaryAction = () => {
|
||||
const config: ValueLinkConfig = { valueRowIndex: getRowIndex(this.data.field, this.getScene()!) };
|
||||
const actionsDefaultFieldConfig = { links: this.options.links ?? [], actions: this.options.actions ?? [] };
|
||||
const frames = this.getScene()?.data?.series;
|
||||
|
||||
if (frames) {
|
||||
const defaultField = getActionsDefaultField(actionsDefaultFieldConfig.links, actionsDefaultFieldConfig.actions);
|
||||
const scopedVars: ScopedVars = {
|
||||
__dataContext: {
|
||||
value: {
|
||||
data: frames,
|
||||
field: defaultField,
|
||||
frame: frames[0],
|
||||
frameIndex: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const actions = getActions(
|
||||
frames[0],
|
||||
defaultField,
|
||||
scopedVars,
|
||||
this.getScene()?.panel.props.replaceVariables!,
|
||||
actionsDefaultFieldConfig.actions,
|
||||
config
|
||||
);
|
||||
return actions[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
handleTooltip = (event: React.MouseEvent) => {
|
||||
const scene = this.getScene();
|
||||
if (scene?.tooltipCallback) {
|
||||
@@ -646,6 +689,11 @@ export class ElementState implements LayerElement {
|
||||
if (primaryDataLink) {
|
||||
window.open(primaryDataLink.href, primaryDataLink.target);
|
||||
}
|
||||
} else if (this.options.oneClickMode === OneClickMode.Action) {
|
||||
let primaryAction = this.getPrimaryAction();
|
||||
if (primaryAction && primaryAction.onClick) {
|
||||
primaryAction.onClick(event);
|
||||
}
|
||||
} else {
|
||||
this.handleTooltip(event);
|
||||
this.onTooltipCallback();
|
||||
|
||||
@@ -281,9 +281,10 @@ export class Scene {
|
||||
};
|
||||
|
||||
render() {
|
||||
const isTooltipValid =
|
||||
(this.tooltip?.element?.getLinks && this.tooltip?.element?.getLinks({}).length > 0) ||
|
||||
this.tooltip?.element?.data?.field;
|
||||
const hasDataLinks = this.tooltip?.element?.getLinks && this.tooltip.element.getLinks({}).length > 0;
|
||||
const hasActions = this.tooltip?.element?.options.actions && this.tooltip.element.options.actions.length > 0;
|
||||
|
||||
const isTooltipValid = hasDataLinks || hasActions || this.tooltip?.element?.data?.field;
|
||||
const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid;
|
||||
|
||||
const sceneDiv = (
|
||||
|
||||
@@ -4,12 +4,18 @@ import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { GrafanaTheme2, VariableSuggestion } from '@grafana/data';
|
||||
import { CustomScrollbar, FieldValidationMessage, Input, Portal, useTheme2 } from '@grafana/ui';
|
||||
import { CustomScrollbar, FieldValidationMessage, Portal, TextArea, useTheme2 } from '@grafana/ui';
|
||||
import { DataLinkSuggestions } from '@grafana/ui/src/components/DataLinks/DataLinkSuggestions';
|
||||
import { Input } from '@grafana/ui/src/components/Input/Input';
|
||||
|
||||
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
|
||||
const ERROR_TOOLTIP_OFFSET = 8;
|
||||
|
||||
export enum HTMLElementType {
|
||||
InputElement = 'input',
|
||||
TextAreaElement = 'textarea',
|
||||
}
|
||||
|
||||
interface SuggestionsInputProps {
|
||||
value?: string | number;
|
||||
onChange: (url: string, callback?: () => void) => void;
|
||||
@@ -18,6 +24,9 @@ interface SuggestionsInputProps {
|
||||
invalid?: boolean;
|
||||
error?: string;
|
||||
width?: number;
|
||||
type?: HTMLElementType;
|
||||
style?: React.CSSProperties;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, inputHeight: number) => {
|
||||
@@ -45,6 +54,9 @@ export const SuggestionsInput = ({
|
||||
placeholder,
|
||||
error,
|
||||
invalid,
|
||||
type = HTMLElementType.InputElement,
|
||||
style,
|
||||
autoFocus = false,
|
||||
}: SuggestionsInputProps) => {
|
||||
const [showingSuggestions, setShowingSuggestions] = useState(false);
|
||||
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
|
||||
@@ -56,7 +68,7 @@ export const SuggestionsInput = ({
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme, inputHeight);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>();
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>();
|
||||
|
||||
// the order of middleware is important!
|
||||
const middleware = [
|
||||
@@ -79,7 +91,7 @@ export const SuggestionsInput = ({
|
||||
});
|
||||
|
||||
const handleRef = useCallback(
|
||||
(ref: HTMLInputElement) => {
|
||||
(ref: HTMLInputElement | HTMLTextAreaElement) => {
|
||||
refs.setReference(ref);
|
||||
|
||||
inputRef.current = ref;
|
||||
@@ -101,7 +113,7 @@ export const SuggestionsInput = ({
|
||||
if (x[startPos - 1] === '$') {
|
||||
input.value = x.slice(0, startPos) + item.value + x.slice(curPos);
|
||||
} else {
|
||||
input.value = x.slice(0, startPos) + '$' + item.value + x.slice(curPos);
|
||||
input.value = x.slice(0, startPos) + '$' + `{${item.value}}` + x.slice(curPos);
|
||||
}
|
||||
|
||||
setVariableValue(input.value);
|
||||
@@ -148,12 +160,12 @@ export const SuggestionsInput = ({
|
||||
[showingSuggestions, suggestions, suggestionsIndex, onVariableSelect]
|
||||
);
|
||||
|
||||
const onValueChanged = React.useCallback((event: FormEvent<HTMLInputElement>) => {
|
||||
const onValueChanged = React.useCallback((event: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setVariableValue(event.currentTarget.value);
|
||||
}, []);
|
||||
|
||||
const onBlur = React.useCallback(
|
||||
(event: FormEvent<HTMLInputElement>) => {
|
||||
(event: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
onChange(event.currentTarget.value);
|
||||
},
|
||||
[onChange]
|
||||
@@ -163,8 +175,17 @@ export const SuggestionsInput = ({
|
||||
setInputHeight(inputRef.current!.clientHeight);
|
||||
}, []);
|
||||
|
||||
const inputProps = {
|
||||
placeholder,
|
||||
invalid,
|
||||
value: variableValue,
|
||||
onChange: onValueChanged,
|
||||
onBlur: onBlur,
|
||||
onKeyDown: onKeyDown,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.inputWrapper}>
|
||||
<div className={styles.inputWrapper} style={style ?? {}}>
|
||||
{showingSuggestions && (
|
||||
<Portal>
|
||||
<div ref={refs.setFloating} style={floatingStyles} className={styles.suggestionsWrapper}>
|
||||
@@ -193,15 +214,15 @@ export const SuggestionsInput = ({
|
||||
<FieldValidationMessage>{error}</FieldValidationMessage>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
invalid={invalid}
|
||||
ref={handleRef}
|
||||
value={variableValue}
|
||||
onChange={onValueChanged}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
{type === HTMLElementType.InputElement ? (
|
||||
<Input {...inputProps} ref={handleRef as unknown as React.RefObject<HTMLInputElement>} autoFocus={autoFocus} />
|
||||
) : (
|
||||
<TextArea
|
||||
{...inputProps}
|
||||
ref={handleRef as unknown as React.RefObject<HTMLTextAreaElement>}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,13 +10,17 @@ import {
|
||||
GrafanaTheme2,
|
||||
formattedValueToString,
|
||||
getFieldDisplayName,
|
||||
ScopedVars,
|
||||
ValueLinkConfig,
|
||||
} from '@grafana/data/src';
|
||||
import { ActionModel } from '@grafana/data/src/types/action';
|
||||
import { Portal, useStyles2, VizTooltipContainer } from '@grafana/ui';
|
||||
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
|
||||
import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter';
|
||||
import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader';
|
||||
import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types';
|
||||
import { CloseButton } from '@grafana/ui/src/components/uPlot/plugins/CloseButton';
|
||||
import { getActions, getActionsDefaultField } from 'app/features/actions/utils';
|
||||
import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
|
||||
import { getRowIndex } from '../utils';
|
||||
@@ -83,6 +87,45 @@ export const CanvasTooltip = ({ scene }: Props) => {
|
||||
});
|
||||
}
|
||||
|
||||
const actions: Array<ActionModel<Field>> = [];
|
||||
const actionLookup = new Set<string>();
|
||||
|
||||
const elementHasActions = (element.options.actions?.length ?? 0) > 0;
|
||||
const frames = scene.data?.series;
|
||||
|
||||
if (elementHasActions && frames) {
|
||||
const defaultField = getActionsDefaultField(element.options.links ?? [], element.options.actions ?? []);
|
||||
const scopedVars: ScopedVars = {
|
||||
__dataContext: {
|
||||
value: {
|
||||
data: frames,
|
||||
field: defaultField,
|
||||
frame: frames[0],
|
||||
frameIndex: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const config: ValueLinkConfig = { valueRowIndex: getRowIndex(element.data.field, scene) };
|
||||
|
||||
const actionsModel = getActions(
|
||||
frames[0],
|
||||
defaultField,
|
||||
scopedVars,
|
||||
scene.panel.props.replaceVariables!,
|
||||
element.options.actions ?? [],
|
||||
config
|
||||
);
|
||||
|
||||
actionsModel.forEach((action) => {
|
||||
const key = `${action.title}/${Math.random()}`;
|
||||
if (!actionLookup.has(key)) {
|
||||
actions.push(action);
|
||||
actionLookup.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{scene.tooltip?.element && scene.tooltip.anchorPoint && (
|
||||
@@ -97,7 +140,7 @@ export const CanvasTooltip = ({ scene }: Props) => {
|
||||
{scene.tooltip.isOpen && <CloseButton style={{ zIndex: 1 }} onClick={onClose} />}
|
||||
<VizTooltipHeader item={headerItem} isPinned={scene.tooltip.isOpen!} />
|
||||
{element.data.text && <VizTooltipContent items={contentItems} isPinned={scene.tooltip.isOpen!} />}
|
||||
{links.length > 0 && <VizTooltipFooter dataLinks={links} />}
|
||||
{(links.length > 0 || actions.length > 0) && <VizTooltipFooter dataLinks={links} actions={actions} />}
|
||||
</section>
|
||||
</VizTooltipContainer>
|
||||
</Portal>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { capitalize, get as lodashGet } from 'lodash';
|
||||
|
||||
import { OneClickMode } from '@grafana/data';
|
||||
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { CanvasElementOptions } from 'app/features/canvas/element';
|
||||
import {
|
||||
DEFAULT_CANVAS_ELEMENT_CONFIG,
|
||||
canvasElementRegistry,
|
||||
DEFAULT_CANVAS_ELEMENT_CONFIG,
|
||||
defaultElementItems,
|
||||
} from 'app/features/canvas/registry';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
@@ -67,6 +68,8 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
|
||||
const current = options?.type ? options.type : DEFAULT_CANVAS_ELEMENT_CONFIG.type;
|
||||
const layerTypes = getElementTypes(opts.scene.shouldShowAdvancedTypes, current).options;
|
||||
|
||||
const actionsEnabled = config.featureToggles.vizActions;
|
||||
|
||||
const isUnsupported =
|
||||
!opts.scene.shouldShowAdvancedTypes && !defaultElementItems.filter((item) => item.id === options?.type).length;
|
||||
|
||||
@@ -120,21 +123,33 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
|
||||
optionBuilder.addBorder(builder, ctx);
|
||||
}
|
||||
|
||||
builder.addRadio({
|
||||
category: ['Data links'],
|
||||
path: 'oneClickMode',
|
||||
name: 'One-click',
|
||||
description: 'When enabled, a single click opens the first link',
|
||||
settings: {
|
||||
options: [
|
||||
const oneClickModeOptions = [
|
||||
{ value: OneClickMode.Off, label: capitalize(OneClickMode.Off) },
|
||||
{ value: OneClickMode.Link, label: capitalize(OneClickMode.Link) },
|
||||
],
|
||||
];
|
||||
|
||||
let oneClickCategory = 'Data links';
|
||||
let oneClickDescription = 'When enabled, a single click opens the first link';
|
||||
|
||||
if (actionsEnabled) {
|
||||
oneClickModeOptions.push({ value: OneClickMode.Action, label: capitalize(OneClickMode.Action) });
|
||||
oneClickCategory += ' and actions';
|
||||
oneClickDescription += ' or action';
|
||||
}
|
||||
|
||||
builder.addRadio({
|
||||
category: [oneClickCategory],
|
||||
path: 'oneClickMode',
|
||||
name: 'One-click',
|
||||
description: oneClickDescription,
|
||||
settings: {
|
||||
options: oneClickModeOptions,
|
||||
},
|
||||
defaultValue: OneClickMode.Off,
|
||||
});
|
||||
|
||||
optionBuilder.addDataLinks(builder, ctx);
|
||||
optionBuilder.addActions(builder, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { capitalize } from 'lodash';
|
||||
|
||||
import { FieldType } from '@grafana/data';
|
||||
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { ConnectionDirection } from 'app/features/canvas/element';
|
||||
import { SVGElements } from 'app/features/canvas/runtime/element';
|
||||
import { ColorDimensionEditor, ResourceDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensions/editors';
|
||||
@@ -11,12 +12,14 @@ import { CanvasConnection, CanvasElementOptions } from '../panelcfg.gen';
|
||||
import { LineStyle } from '../types';
|
||||
|
||||
import { LineStyleEditor } from './LineStyleEditor';
|
||||
import { ActionsEditor } from './element/ActionsEditor';
|
||||
import { DataLinksEditor } from './element/DataLinksEditor';
|
||||
|
||||
interface OptionSuppliers {
|
||||
addBackground: PanelOptionsSupplier<CanvasElementOptions>;
|
||||
addBorder: PanelOptionsSupplier<CanvasElementOptions>;
|
||||
addDataLinks: PanelOptionsSupplier<CanvasElementOptions>;
|
||||
addActions: PanelOptionsSupplier<CanvasElementOptions>;
|
||||
addColor: PanelOptionsSupplier<CanvasConnection>;
|
||||
addSize: PanelOptionsSupplier<CanvasConnection>;
|
||||
addRadius: PanelOptionsSupplier<CanvasConnection>;
|
||||
@@ -210,7 +213,7 @@ export const optionBuilder: OptionSuppliers = {
|
||||
|
||||
addDataLinks: (builder, context) => {
|
||||
builder.addCustomEditor({
|
||||
category: ['Data links'],
|
||||
category: config.featureToggles.vizActions ? ['Data links and actions'] : ['Data links'],
|
||||
id: 'dataLinks',
|
||||
path: 'links',
|
||||
name: 'Links',
|
||||
@@ -218,4 +221,16 @@ export const optionBuilder: OptionSuppliers = {
|
||||
settings: context.options,
|
||||
});
|
||||
},
|
||||
|
||||
addActions: (builder, context) => {
|
||||
builder.addCustomEditor({
|
||||
category: ['Data links and actions'],
|
||||
id: 'actions',
|
||||
path: 'actions',
|
||||
name: 'Actions',
|
||||
editor: ActionsEditor,
|
||||
settings: context.options,
|
||||
showIf: () => config.featureToggles.vizActions,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user