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.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
[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": [
|
"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.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
[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.", "0"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
[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": [
|
"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 />", "0"],
|
||||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
[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": [
|
"public/app/features/transformers/standardTransformers.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[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": [
|
"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 />", "0"],
|
||||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
[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
|
# Temp owners until Enterprise team takes over
|
||||||
/public/app/features/migrate-to-cloud @grafana/grafana-frontend-platform
|
/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/auth-config/ @grafana/identity-squad
|
||||||
/public/app/features/annotations/ @grafana/dashboards-squad
|
/public/app/features/annotations/ @grafana/dashboards-squad
|
||||||
/public/app/features/api-keys/ @grafana/identity-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) |
|
| `lokiExperimentalStreaming` | Support new streaming approach for loki (prototype, needs special loki build) |
|
||||||
| `storage` | Configurable storage for dashboards, datasources, and resources |
|
| `storage` | Configurable storage for dashboards, datasources, and resources |
|
||||||
| `canvasPanelNesting` | Allow elements nesting |
|
| `canvasPanelNesting` | Allow elements nesting |
|
||||||
|
| `vizActions` | Allow actions in visualizations |
|
||||||
| `disableSecretsCompatibility` | Disable duplicated secret storage in legacy tables |
|
| `disableSecretsCompatibility` | Disable duplicated secret storage in legacy tables |
|
||||||
| `logRequestsInstrumentedAsUnknown` | Logs the path for requests that are instrumented as unknown |
|
| `logRequestsInstrumentedAsUnknown` | Logs the path for requests that are instrumented as unknown |
|
||||||
| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema |
|
| `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
|
* 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) {
|
if (fieldScopedVars?.__dataContext) {
|
||||||
return {
|
return {
|
||||||
value: {
|
value: {
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export {
|
|||||||
validateFieldConfig,
|
validateFieldConfig,
|
||||||
applyRawFieldOverrides,
|
applyRawFieldOverrides,
|
||||||
useFieldOverrides,
|
useFieldOverrides,
|
||||||
|
getFieldDataContextClone,
|
||||||
} from './field/fieldOverrides';
|
} from './field/fieldOverrides';
|
||||||
export { getFieldDisplayValuesProxy } from './field/getFieldDisplayValuesProxy';
|
export { getFieldDisplayValuesProxy } from './field/getFieldDisplayValuesProxy';
|
||||||
export {
|
export {
|
||||||
@@ -800,6 +801,14 @@ export {
|
|||||||
VariableSuggestionsScope,
|
VariableSuggestionsScope,
|
||||||
OneClickMode,
|
OneClickMode,
|
||||||
} from './types/dataLink';
|
} from './types/dataLink';
|
||||||
|
export {
|
||||||
|
type Action,
|
||||||
|
type ActionModel,
|
||||||
|
HttpRequestMethod,
|
||||||
|
defaultActionConfig,
|
||||||
|
contentTypeOptions,
|
||||||
|
httpMethodOptions,
|
||||||
|
} from './types/action';
|
||||||
export { DataFrameType } from './types/dataFrameTypes';
|
export { DataFrameType } from './types/dataFrameTypes';
|
||||||
export {
|
export {
|
||||||
FieldType,
|
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>;
|
internal?: InternalDataLink<T>;
|
||||||
|
|
||||||
origin?: DataLinkConfigOrigin;
|
origin?: DataLinkConfigOrigin;
|
||||||
sortIndex?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,6 +130,7 @@ export enum VariableSuggestionsScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum OneClickMode {
|
export enum OneClickMode {
|
||||||
|
Action = 'action',
|
||||||
Link = 'link',
|
Link = 'link',
|
||||||
Off = 'off',
|
Off = 'off',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface FeatureToggles {
|
|||||||
autoMigrateXYChartPanel?: boolean;
|
autoMigrateXYChartPanel?: boolean;
|
||||||
disableAngular?: boolean;
|
disableAngular?: boolean;
|
||||||
canvasPanelNesting?: boolean;
|
canvasPanelNesting?: boolean;
|
||||||
|
vizActions?: boolean;
|
||||||
disableSecretsCompatibility?: boolean;
|
disableSecretsCompatibility?: boolean;
|
||||||
logRequestsInstrumentedAsUnknown?: boolean;
|
logRequestsInstrumentedAsUnknown?: boolean;
|
||||||
topnav?: 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,37 +1,50 @@
|
|||||||
import { css } from '@emotion/css';
|
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 { Button, ButtonProps, DataLinkButton, Stack } from '..';
|
||||||
import { useStyles2 } from '../../themes';
|
import { useStyles2 } from '../../themes';
|
||||||
|
import { ActionButton } from '../Actions/ActionButton';
|
||||||
|
|
||||||
interface VizTooltipFooterProps {
|
interface VizTooltipFooterProps {
|
||||||
dataLinks: Array<LinkModel<Field>>;
|
dataLinks: Array<LinkModel<Field>>;
|
||||||
|
actions?: Array<ActionModel<Field>>;
|
||||||
annotate?: () => void;
|
annotate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ADD_ANNOTATION_ID = 'add-annotation-button';
|
export const ADD_ANNOTATION_ID = 'add-annotation-button';
|
||||||
|
|
||||||
export const VizTooltipFooter = ({ dataLinks, annotate }: VizTooltipFooterProps) => {
|
const renderDataLinks = (dataLinks: LinkModel[]) => {
|
||||||
const styles = useStyles2(getStyles);
|
const buttonProps: ButtonProps = {
|
||||||
|
variant: 'secondary',
|
||||||
const renderDataLinks = () => {
|
|
||||||
const buttonProps: ButtonProps = {
|
|
||||||
variant: 'secondary',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack direction="column" justifyContent="flex-start">
|
|
||||||
{dataLinks.map((link, i) => (
|
|
||||||
<DataLinkButton key={i} link={link} buttonProps={buttonProps} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="column" justifyContent="flex-start">
|
||||||
|
{dataLinks.map((link, i) => (
|
||||||
|
<DataLinkButton key={i} link={link} buttonProps={buttonProps} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<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 && (
|
{annotate != null && (
|
||||||
<div className={styles.addAnnotations}>
|
<div className={styles.addAnnotations}>
|
||||||
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
|
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
|
||||||
|
|||||||
@@ -165,6 +165,14 @@ var (
|
|||||||
Owner: grafanaDatavizSquad,
|
Owner: grafanaDatavizSquad,
|
||||||
HideFromAdminPage: true,
|
HideFromAdminPage: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "vizActions",
|
||||||
|
Description: "Allow actions in visualizations",
|
||||||
|
Stage: FeatureStageExperimental,
|
||||||
|
FrontendOnly: true,
|
||||||
|
Owner: grafanaDatavizSquad,
|
||||||
|
HideFromAdminPage: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "disableSecretsCompatibility",
|
Name: "disableSecretsCompatibility",
|
||||||
Description: "Disable duplicated secret storage in legacy tables",
|
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
|
autoMigrateXYChartPanel,GA,@grafana/dataviz-squad,false,false,true
|
||||||
disableAngular,preview,@grafana/dataviz-squad,false,false,true
|
disableAngular,preview,@grafana/dataviz-squad,false,false,true
|
||||||
canvasPanelNesting,experimental,@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
|
disableSecretsCompatibility,experimental,@grafana/hosted-grafana-team,false,true,false
|
||||||
logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,false,false,false
|
logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,false,false,false
|
||||||
topnav,deprecated,@grafana/grafana-frontend-platform,false,false,false
|
topnav,deprecated,@grafana/grafana-frontend-platform,false,false,false
|
||||||
|
|||||||
|
@@ -87,6 +87,10 @@ const (
|
|||||||
// Allow elements nesting
|
// Allow elements nesting
|
||||||
FlagCanvasPanelNesting = "canvasPanelNesting"
|
FlagCanvasPanelNesting = "canvasPanelNesting"
|
||||||
|
|
||||||
|
// FlagVizActions
|
||||||
|
// Allow actions in visualizations
|
||||||
|
FlagVizActions = "vizActions"
|
||||||
|
|
||||||
// FlagDisableSecretsCompatibility
|
// FlagDisableSecretsCompatibility
|
||||||
// Disable duplicated secret storage in legacy tables
|
// Disable duplicated secret storage in legacy tables
|
||||||
FlagDisableSecretsCompatibility = "disableSecretsCompatibility"
|
FlagDisableSecretsCompatibility = "disableSecretsCompatibility"
|
||||||
|
|||||||
@@ -2831,6 +2831,20 @@
|
|||||||
"requiresRestart": true
|
"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": {
|
"metadata": {
|
||||||
"name": "vizAndWidgetSplit",
|
"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 { 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 { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
|
||||||
import { ColorDimensionConfig, ScaleDimensionConfig } from '@grafana/schema';
|
import { ColorDimensionConfig, ScaleDimensionConfig } from '@grafana/schema';
|
||||||
import { config } from 'app/core/config';
|
import { config } from 'app/core/config';
|
||||||
@@ -33,6 +33,7 @@ export interface CanvasElementOptions<TConfig = any> {
|
|||||||
border?: LineConfig;
|
border?: LineConfig;
|
||||||
connections?: CanvasConnection[];
|
connections?: CanvasConnection[];
|
||||||
links?: DataLink[];
|
links?: DataLink[];
|
||||||
|
actions?: Action[];
|
||||||
oneClickMode?: OneClickMode;
|
oneClickMode?: OneClickMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
import { OnDrag, OnResize, OnRotate } from 'react-moveable/declaration/types';
|
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 { LayerElement } from 'app/core/components/Layers/types';
|
||||||
import { notFoundItem } from 'app/features/canvas/elements/notFound';
|
import { notFoundItem } from 'app/features/canvas/elements/notFound';
|
||||||
import { DimensionContext } from 'app/features/dimensions';
|
import { DimensionContext } from 'app/features/dimensions';
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from 'app/plugins/panel/canvas/panelcfg.gen';
|
} from 'app/plugins/panel/canvas/panelcfg.gen';
|
||||||
import { getConnectionsByTarget, getRowIndex, isConnectionTarget } from 'app/plugins/panel/canvas/utils';
|
import { getConnectionsByTarget, getRowIndex, isConnectionTarget } from 'app/plugins/panel/canvas/utils';
|
||||||
|
|
||||||
|
import { getActions, getActionsDefaultField } from '../../actions/utils';
|
||||||
import { CanvasElementItem, CanvasElementOptions } from '../element';
|
import { CanvasElementItem, CanvasElementOptions } from '../element';
|
||||||
import { canvasElementRegistry } from '../registry';
|
import { canvasElementRegistry } from '../registry';
|
||||||
|
|
||||||
@@ -379,7 +380,7 @@ export class ElementState implements LayerElement {
|
|||||||
const defaultField = {
|
const defaultField = {
|
||||||
name: 'Default field',
|
name: 'Default field',
|
||||||
type: FieldType.string,
|
type: FieldType.string,
|
||||||
config: { links: this.options.links ?? [] },
|
config: { links: this.options.links ?? [], actions: this.options.actions ?? [] },
|
||||||
values: [],
|
values: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -597,12 +598,22 @@ export class ElementState implements LayerElement {
|
|||||||
|
|
||||||
const shouldHandleOneClickLink =
|
const shouldHandleOneClickLink =
|
||||||
this.options.oneClickMode === OneClickMode.Link && this.options.links && this.options.links.length > 0;
|
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) {
|
if (shouldHandleOneClickLink && this.div) {
|
||||||
const primaryDataLink = this.getPrimaryDataLink();
|
const primaryDataLink = this.getPrimaryDataLink();
|
||||||
if (primaryDataLink) {
|
if (primaryDataLink) {
|
||||||
this.div.style.cursor = 'pointer';
|
this.div.style.cursor = 'pointer';
|
||||||
this.div.title = `Navigate to ${primaryDataLink.title === '' ? 'data link' : primaryDataLink.title}`;
|
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;
|
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) => {
|
handleTooltip = (event: React.MouseEvent) => {
|
||||||
const scene = this.getScene();
|
const scene = this.getScene();
|
||||||
if (scene?.tooltipCallback) {
|
if (scene?.tooltipCallback) {
|
||||||
@@ -646,6 +689,11 @@ export class ElementState implements LayerElement {
|
|||||||
if (primaryDataLink) {
|
if (primaryDataLink) {
|
||||||
window.open(primaryDataLink.href, primaryDataLink.target);
|
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 {
|
} else {
|
||||||
this.handleTooltip(event);
|
this.handleTooltip(event);
|
||||||
this.onTooltipCallback();
|
this.onTooltipCallback();
|
||||||
|
|||||||
@@ -281,9 +281,10 @@ export class Scene {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const isTooltipValid =
|
const hasDataLinks = this.tooltip?.element?.getLinks && this.tooltip.element.getLinks({}).length > 0;
|
||||||
(this.tooltip?.element?.getLinks && this.tooltip?.element?.getLinks({}).length > 0) ||
|
const hasActions = this.tooltip?.element?.options.actions && this.tooltip.element.options.actions.length > 0;
|
||||||
this.tooltip?.element?.data?.field;
|
|
||||||
|
const isTooltipValid = hasDataLinks || hasActions || this.tooltip?.element?.data?.field;
|
||||||
const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid;
|
const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid;
|
||||||
|
|
||||||
const sceneDiv = (
|
const sceneDiv = (
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2, VariableSuggestion } from '@grafana/data';
|
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 { 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 modulo = (a: number, n: number) => a - n * Math.floor(a / n);
|
||||||
const ERROR_TOOLTIP_OFFSET = 8;
|
const ERROR_TOOLTIP_OFFSET = 8;
|
||||||
|
|
||||||
|
export enum HTMLElementType {
|
||||||
|
InputElement = 'input',
|
||||||
|
TextAreaElement = 'textarea',
|
||||||
|
}
|
||||||
|
|
||||||
interface SuggestionsInputProps {
|
interface SuggestionsInputProps {
|
||||||
value?: string | number;
|
value?: string | number;
|
||||||
onChange: (url: string, callback?: () => void) => void;
|
onChange: (url: string, callback?: () => void) => void;
|
||||||
@@ -18,6 +24,9 @@ interface SuggestionsInputProps {
|
|||||||
invalid?: boolean;
|
invalid?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
type?: HTMLElementType;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2, inputHeight: number) => {
|
const getStyles = (theme: GrafanaTheme2, inputHeight: number) => {
|
||||||
@@ -45,6 +54,9 @@ export const SuggestionsInput = ({
|
|||||||
placeholder,
|
placeholder,
|
||||||
error,
|
error,
|
||||||
invalid,
|
invalid,
|
||||||
|
type = HTMLElementType.InputElement,
|
||||||
|
style,
|
||||||
|
autoFocus = false,
|
||||||
}: SuggestionsInputProps) => {
|
}: SuggestionsInputProps) => {
|
||||||
const [showingSuggestions, setShowingSuggestions] = useState(false);
|
const [showingSuggestions, setShowingSuggestions] = useState(false);
|
||||||
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
|
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
|
||||||
@@ -56,7 +68,7 @@ export const SuggestionsInput = ({
|
|||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getStyles(theme, inputHeight);
|
const styles = getStyles(theme, inputHeight);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>();
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>();
|
||||||
|
|
||||||
// the order of middleware is important!
|
// the order of middleware is important!
|
||||||
const middleware = [
|
const middleware = [
|
||||||
@@ -79,7 +91,7 @@ export const SuggestionsInput = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleRef = useCallback(
|
const handleRef = useCallback(
|
||||||
(ref: HTMLInputElement) => {
|
(ref: HTMLInputElement | HTMLTextAreaElement) => {
|
||||||
refs.setReference(ref);
|
refs.setReference(ref);
|
||||||
|
|
||||||
inputRef.current = ref;
|
inputRef.current = ref;
|
||||||
@@ -101,7 +113,7 @@ export const SuggestionsInput = ({
|
|||||||
if (x[startPos - 1] === '$') {
|
if (x[startPos - 1] === '$') {
|
||||||
input.value = x.slice(0, startPos) + item.value + x.slice(curPos);
|
input.value = x.slice(0, startPos) + item.value + x.slice(curPos);
|
||||||
} else {
|
} 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);
|
setVariableValue(input.value);
|
||||||
@@ -148,12 +160,12 @@ export const SuggestionsInput = ({
|
|||||||
[showingSuggestions, suggestions, suggestionsIndex, onVariableSelect]
|
[showingSuggestions, suggestions, suggestionsIndex, onVariableSelect]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onValueChanged = React.useCallback((event: FormEvent<HTMLInputElement>) => {
|
const onValueChanged = React.useCallback((event: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
setVariableValue(event.currentTarget.value);
|
setVariableValue(event.currentTarget.value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onBlur = React.useCallback(
|
const onBlur = React.useCallback(
|
||||||
(event: FormEvent<HTMLInputElement>) => {
|
(event: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
onChange(event.currentTarget.value);
|
onChange(event.currentTarget.value);
|
||||||
},
|
},
|
||||||
[onChange]
|
[onChange]
|
||||||
@@ -163,8 +175,17 @@ export const SuggestionsInput = ({
|
|||||||
setInputHeight(inputRef.current!.clientHeight);
|
setInputHeight(inputRef.current!.clientHeight);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const inputProps = {
|
||||||
|
placeholder,
|
||||||
|
invalid,
|
||||||
|
value: variableValue,
|
||||||
|
onChange: onValueChanged,
|
||||||
|
onBlur: onBlur,
|
||||||
|
onKeyDown: onKeyDown,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.inputWrapper}>
|
<div className={styles.inputWrapper} style={style ?? {}}>
|
||||||
{showingSuggestions && (
|
{showingSuggestions && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div ref={refs.setFloating} style={floatingStyles} className={styles.suggestionsWrapper}>
|
<div ref={refs.setFloating} style={floatingStyles} className={styles.suggestionsWrapper}>
|
||||||
@@ -193,15 +214,15 @@ export const SuggestionsInput = ({
|
|||||||
<FieldValidationMessage>{error}</FieldValidationMessage>
|
<FieldValidationMessage>{error}</FieldValidationMessage>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Input
|
{type === HTMLElementType.InputElement ? (
|
||||||
placeholder={placeholder}
|
<Input {...inputProps} ref={handleRef as unknown as React.RefObject<HTMLInputElement>} autoFocus={autoFocus} />
|
||||||
invalid={invalid}
|
) : (
|
||||||
ref={handleRef}
|
<TextArea
|
||||||
value={variableValue}
|
{...inputProps}
|
||||||
onChange={onValueChanged}
|
ref={handleRef as unknown as React.RefObject<HTMLTextAreaElement>}
|
||||||
onBlur={onBlur}
|
autoFocus={autoFocus}
|
||||||
onKeyDown={onKeyDown}
|
/>
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import {
|
|||||||
GrafanaTheme2,
|
GrafanaTheme2,
|
||||||
formattedValueToString,
|
formattedValueToString,
|
||||||
getFieldDisplayName,
|
getFieldDisplayName,
|
||||||
|
ScopedVars,
|
||||||
|
ValueLinkConfig,
|
||||||
} from '@grafana/data/src';
|
} from '@grafana/data/src';
|
||||||
|
import { ActionModel } from '@grafana/data/src/types/action';
|
||||||
import { Portal, useStyles2, VizTooltipContainer } from '@grafana/ui';
|
import { Portal, useStyles2, VizTooltipContainer } from '@grafana/ui';
|
||||||
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
|
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
|
||||||
import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter';
|
import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter';
|
||||||
import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader';
|
import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader';
|
||||||
import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types';
|
import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types';
|
||||||
import { CloseButton } from '@grafana/ui/src/components/uPlot/plugins/CloseButton';
|
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 { Scene } from 'app/features/canvas/runtime/scene';
|
||||||
|
|
||||||
import { getRowIndex } from '../utils';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{scene.tooltip?.element && scene.tooltip.anchorPoint && (
|
{scene.tooltip?.element && scene.tooltip.anchorPoint && (
|
||||||
@@ -97,7 +140,7 @@ export const CanvasTooltip = ({ scene }: Props) => {
|
|||||||
{scene.tooltip.isOpen && <CloseButton style={{ zIndex: 1 }} onClick={onClose} />}
|
{scene.tooltip.isOpen && <CloseButton style={{ zIndex: 1 }} onClick={onClose} />}
|
||||||
<VizTooltipHeader item={headerItem} isPinned={scene.tooltip.isOpen!} />
|
<VizTooltipHeader item={headerItem} isPinned={scene.tooltip.isOpen!} />
|
||||||
{element.data.text && <VizTooltipContent items={contentItems} 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>
|
</section>
|
||||||
</VizTooltipContainer>
|
</VizTooltipContainer>
|
||||||
</Portal>
|
</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 { OneClickMode } from '@grafana/data';
|
||||||
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
|
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
import { CanvasElementOptions } from 'app/features/canvas/element';
|
import { CanvasElementOptions } from 'app/features/canvas/element';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CANVAS_ELEMENT_CONFIG,
|
|
||||||
canvasElementRegistry,
|
canvasElementRegistry,
|
||||||
|
DEFAULT_CANVAS_ELEMENT_CONFIG,
|
||||||
defaultElementItems,
|
defaultElementItems,
|
||||||
} from 'app/features/canvas/registry';
|
} from 'app/features/canvas/registry';
|
||||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
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 current = options?.type ? options.type : DEFAULT_CANVAS_ELEMENT_CONFIG.type;
|
||||||
const layerTypes = getElementTypes(opts.scene.shouldShowAdvancedTypes, current).options;
|
const layerTypes = getElementTypes(opts.scene.shouldShowAdvancedTypes, current).options;
|
||||||
|
|
||||||
|
const actionsEnabled = config.featureToggles.vizActions;
|
||||||
|
|
||||||
const isUnsupported =
|
const isUnsupported =
|
||||||
!opts.scene.shouldShowAdvancedTypes && !defaultElementItems.filter((item) => item.id === options?.type).length;
|
!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);
|
optionBuilder.addBorder(builder, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
builder.addRadio({
|
||||||
category: ['Data links'],
|
category: [oneClickCategory],
|
||||||
path: 'oneClickMode',
|
path: 'oneClickMode',
|
||||||
name: 'One-click',
|
name: 'One-click',
|
||||||
description: 'When enabled, a single click opens the first link',
|
description: oneClickDescription,
|
||||||
settings: {
|
settings: {
|
||||||
options: [
|
options: oneClickModeOptions,
|
||||||
{ value: OneClickMode.Off, label: capitalize(OneClickMode.Off) },
|
|
||||||
{ value: OneClickMode.Link, label: capitalize(OneClickMode.Link) },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
defaultValue: OneClickMode.Off,
|
defaultValue: OneClickMode.Off,
|
||||||
});
|
});
|
||||||
|
|
||||||
optionBuilder.addDataLinks(builder, ctx);
|
optionBuilder.addDataLinks(builder, ctx);
|
||||||
|
optionBuilder.addActions(builder, ctx);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { capitalize } from 'lodash';
|
|||||||
|
|
||||||
import { FieldType } from '@grafana/data';
|
import { FieldType } from '@grafana/data';
|
||||||
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
|
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
import { ConnectionDirection } from 'app/features/canvas/element';
|
import { ConnectionDirection } from 'app/features/canvas/element';
|
||||||
import { SVGElements } from 'app/features/canvas/runtime/element';
|
import { SVGElements } from 'app/features/canvas/runtime/element';
|
||||||
import { ColorDimensionEditor, ResourceDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensions/editors';
|
import { ColorDimensionEditor, ResourceDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensions/editors';
|
||||||
@@ -11,12 +12,14 @@ import { CanvasConnection, CanvasElementOptions } from '../panelcfg.gen';
|
|||||||
import { LineStyle } from '../types';
|
import { LineStyle } from '../types';
|
||||||
|
|
||||||
import { LineStyleEditor } from './LineStyleEditor';
|
import { LineStyleEditor } from './LineStyleEditor';
|
||||||
|
import { ActionsEditor } from './element/ActionsEditor';
|
||||||
import { DataLinksEditor } from './element/DataLinksEditor';
|
import { DataLinksEditor } from './element/DataLinksEditor';
|
||||||
|
|
||||||
interface OptionSuppliers {
|
interface OptionSuppliers {
|
||||||
addBackground: PanelOptionsSupplier<CanvasElementOptions>;
|
addBackground: PanelOptionsSupplier<CanvasElementOptions>;
|
||||||
addBorder: PanelOptionsSupplier<CanvasElementOptions>;
|
addBorder: PanelOptionsSupplier<CanvasElementOptions>;
|
||||||
addDataLinks: PanelOptionsSupplier<CanvasElementOptions>;
|
addDataLinks: PanelOptionsSupplier<CanvasElementOptions>;
|
||||||
|
addActions: PanelOptionsSupplier<CanvasElementOptions>;
|
||||||
addColor: PanelOptionsSupplier<CanvasConnection>;
|
addColor: PanelOptionsSupplier<CanvasConnection>;
|
||||||
addSize: PanelOptionsSupplier<CanvasConnection>;
|
addSize: PanelOptionsSupplier<CanvasConnection>;
|
||||||
addRadius: PanelOptionsSupplier<CanvasConnection>;
|
addRadius: PanelOptionsSupplier<CanvasConnection>;
|
||||||
@@ -210,7 +213,7 @@ export const optionBuilder: OptionSuppliers = {
|
|||||||
|
|
||||||
addDataLinks: (builder, context) => {
|
addDataLinks: (builder, context) => {
|
||||||
builder.addCustomEditor({
|
builder.addCustomEditor({
|
||||||
category: ['Data links'],
|
category: config.featureToggles.vizActions ? ['Data links and actions'] : ['Data links'],
|
||||||
id: 'dataLinks',
|
id: 'dataLinks',
|
||||||
path: 'links',
|
path: 'links',
|
||||||
name: 'Links',
|
name: 'Links',
|
||||||
@@ -218,4 +221,16 @@ export const optionBuilder: OptionSuppliers = {
|
|||||||
settings: context.options,
|
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