Canvas: Add actions support (#90677)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
pull/93082/head^2
Adela Almasan 10 months ago committed by GitHub
parent b89f3f8115
commit af48d3db1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 22
      .betterer.results
  2. 1
      .github/CODEOWNERS
  3. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  4. 2
      packages/grafana-data/src/field/fieldOverrides.ts
  5. 9
      packages/grafana-data/src/index.ts
  6. 70
      packages/grafana-data/src/types/action.ts
  7. 2
      packages/grafana-data/src/types/dataLink.ts
  8. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  9. 18
      packages/grafana-ui/src/components/Actions/ActionButton.tsx
  10. 47
      packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx
  11. 8
      pkg/services/featuremgmt/registry.go
  12. 1
      pkg/services/featuremgmt/toggles_gen.csv
  13. 4
      pkg/services/featuremgmt/toggles_gen.go
  14. 14
      pkg/services/featuremgmt/toggles_gen.json
  15. 185
      public/app/features/actions/ActionEditor.tsx
  16. 52
      public/app/features/actions/ActionEditorModalContent.tsx
  17. 192
      public/app/features/actions/ActionsInlineEditor.tsx
  18. 111
      public/app/features/actions/ActionsListItem.tsx
  19. 130
      public/app/features/actions/ParamsEditor.tsx
  20. 148
      public/app/features/actions/utils.ts
  21. 3
      public/app/features/canvas/element.ts
  22. 52
      public/app/features/canvas/runtime/element.tsx
  23. 7
      public/app/features/canvas/runtime/scene.tsx
  24. 53
      public/app/features/transformers/suggestionsInput/SuggestionsInput.tsx
  25. 45
      public/app/plugins/panel/canvas/components/CanvasTooltip.tsx
  26. 20
      public/app/plugins/panel/canvas/editor/element/ActionsEditor.tsx
  27. 29
      public/app/plugins/panel/canvas/editor/element/elementEditor.tsx
  28. 17
      public/app/plugins/panel/canvas/editor/options.ts

@ -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"],

@ -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,

@ -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;

@ -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 { 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 buttonProps: ButtonProps = {
variant: 'secondary',
};
return (
<Stack direction="column" justifyContent="flex-start">
{dataLinks.map((link, i) => (
<DataLinkButton key={i} link={link} buttonProps={buttonProps} />
))}
</Stack>
);
const renderDataLinks = (dataLinks: LinkModel[]) => {
const buttonProps: ButtonProps = {
variant: 'secondary',
};
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 (
<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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
19 autoMigrateXYChartPanel GA @grafana/dataviz-squad false false true
20 disableAngular preview @grafana/dataviz-squad false false true
21 canvasPanelNesting experimental @grafana/dataviz-squad false false true
22 vizActions experimental @grafana/dataviz-squad false false true
23 disableSecretsCompatibility experimental @grafana/hosted-grafana-team false true false
24 logRequestsInstrumentedAsUnknown experimental @grafana/hosted-grafana-team false false false
25 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",

@ -0,0 +1,185 @@
import { css } from '@emotion/css';
import { memo } from 'react';
import { Action, GrafanaTheme2, httpMethodOptions, HttpRequestMethod, VariableSuggestion } from '@grafana/data';
import { Field } from '@grafana/ui/src/components/Forms/Field';
import { InlineField } from '@grafana/ui/src/components/Forms/InlineField';
import { InlineFieldRow } from '@grafana/ui/src/components/Forms/InlineFieldRow';
import { RadioButtonGroup } from '@grafana/ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup';
import { JSONFormatter } from '@grafana/ui/src/components/JSONFormatter/JSONFormatter';
import { useStyles2 } from '@grafana/ui/src/themes';
import { HTMLElementType, SuggestionsInput } from '../transformers/suggestionsInput/SuggestionsInput';
import { ParamsEditor } from './ParamsEditor';
interface ActionEditorProps {
index: number;
value: Action;
onChange: (index: number, action: Action) => void;
suggestions: VariableSuggestion[];
}
const LABEL_WIDTH = 13;
export const ActionEditor = memo(({ index, value, onChange, suggestions }: ActionEditorProps) => {
const styles = useStyles2(getStyles);
const onTitleChange = (title: string) => {
onChange(index, { ...value, title });
};
const onUrlChange = (url: string) => {
onChange(index, {
...value,
options: {
...value.options,
url,
},
});
};
const onBodyChange = (body: string) => {
onChange(index, {
...value,
options: {
...value.options,
body,
},
});
};
const onMethodChange = (method: HttpRequestMethod) => {
onChange(index, {
...value,
options: {
...value.options,
method,
},
});
};
const onQueryParamsChange = (queryParams: Array<[string, string]>) => {
onChange(index, {
...value,
options: {
...value.options,
queryParams,
},
});
};
const onHeadersChange = (headers: Array<[string, string]>) => {
onChange(index, {
...value,
options: {
...value.options,
headers,
},
});
};
const renderJSON = (data = '{}') => {
try {
const json = JSON.parse(data);
return <JSONFormatter json={json} />;
} catch (error) {
if (error instanceof Error) {
return `Invalid JSON provided: ${error.message}`;
} else {
return 'Invalid JSON provided';
}
}
};
const shouldRenderJSON =
value.options.method !== HttpRequestMethod.GET &&
value.options.headers?.some(([name, value]) => name === 'Content-Type' && value === 'application/json');
return (
<div className={styles.listItem}>
<Field label="Title">
<SuggestionsInput
value={value.title}
onChange={onTitleChange}
suggestions={suggestions}
autoFocus={value.title === ''}
placeholder="Action title"
/>
</Field>
<InlineFieldRow>
<InlineField label="Method" labelWidth={LABEL_WIDTH} grow={true}>
<RadioButtonGroup<HttpRequestMethod>
value={value?.options.method}
options={httpMethodOptions}
onChange={onMethodChange}
fullWidth
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="URL" labelWidth={LABEL_WIDTH} grow={true}>
<SuggestionsInput
value={value.options.url}
onChange={onUrlChange}
suggestions={suggestions}
placeholder="URL"
/>
</InlineField>
</InlineFieldRow>
<Field label="Query parameters" className={styles.fieldGap}>
<ParamsEditor
value={value?.options.queryParams ?? []}
onChange={onQueryParamsChange}
suggestions={suggestions}
/>
</Field>
<Field label="Headers">
<ParamsEditor
value={value?.options.headers ?? []}
onChange={onHeadersChange}
suggestions={suggestions}
contentTypeHeader={true}
/>
</Field>
{value?.options.method !== HttpRequestMethod.GET && (
<Field label="Body">
<SuggestionsInput
value={value.options.body}
onChange={onBodyChange}
suggestions={suggestions}
type={HTMLElementType.TextAreaElement}
/>
</Field>
)}
{shouldRenderJSON && (
<>
<br />
{renderJSON(value?.options.body)}
</>
)}
</div>
);
});
const getStyles = (theme: GrafanaTheme2) => ({
listItem: css({
marginBottom: theme.spacing(),
}),
infoText: css({
paddingBottom: theme.spacing(2),
marginLeft: '66px',
color: theme.colors.text.secondary,
}),
fieldGap: css({
marginTop: theme.spacing(2),
}),
});
ActionEditor.displayName = 'ActionEditor';

@ -0,0 +1,52 @@
import { useState } from 'react';
import { Action, DataFrame, VariableSuggestion } from '@grafana/data';
import { Button } from '@grafana/ui/src/components/Button';
import { Modal } from '@grafana/ui/src/components/Modal/Modal';
import { ActionEditor } from './ActionEditor';
interface ActionEditorModalContentProps {
action: Action;
index: number;
data: DataFrame[];
onSave: (index: number, action: Action) => void;
onCancel: (index: number) => void;
getSuggestions: () => VariableSuggestion[];
}
export const ActionEditorModalContent = ({
action,
index,
onSave,
onCancel,
getSuggestions,
}: ActionEditorModalContentProps) => {
const [dirtyAction, setDirtyAction] = useState(action);
return (
<>
<ActionEditor
value={dirtyAction}
index={index}
onChange={(index, action) => {
setDirtyAction(action);
}}
suggestions={getSuggestions()}
/>
<Modal.ButtonRow>
<Button variant="secondary" onClick={() => onCancel(index)} fill="outline">
Cancel
</Button>
<Button
onClick={() => {
onSave(index, dirtyAction);
}}
disabled={dirtyAction.title.trim() === '' || dirtyAction.options.url.trim() === ''}
>
Save
</Button>
</Modal.ButtonRow>
</>
);
};

@ -0,0 +1,192 @@
import { css } from '@emotion/css';
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
import { cloneDeep } from 'lodash';
import { ReactNode, useEffect, useState } from 'react';
import { Action, DataFrame, GrafanaTheme2, defaultActionConfig, VariableSuggestion } from '@grafana/data';
import { Button } from '@grafana/ui/src/components/Button';
import { Modal } from '@grafana/ui/src/components/Modal/Modal';
import { useStyles2 } from '@grafana/ui/src/themes';
import { ActionEditorModalContent } from './ActionEditorModalContent';
import { ActionListItem } from './ActionsListItem';
interface ActionsInlineEditorProps {
actions?: Action[];
onChange: (actions: Action[]) => void;
data: DataFrame[];
getSuggestions: () => VariableSuggestion[];
showOneClick?: boolean;
}
export const ActionsInlineEditor = ({
actions,
onChange,
data,
getSuggestions,
showOneClick = false,
}: ActionsInlineEditorProps) => {
const [editIndex, setEditIndex] = useState<number | null>(null);
const [isNew, setIsNew] = useState(false);
const [actionsSafe, setActionsSafe] = useState<Action[]>([]);
useEffect(() => {
setActionsSafe(actions ?? []);
}, [actions]);
const styles = useStyles2(getActionsInlineEditorStyle);
const isEditing = editIndex !== null;
const onActionChange = (index: number, action: Action) => {
if (isNew) {
if (action.title.trim() === '') {
setIsNew(false);
setEditIndex(null);
return;
} else {
setEditIndex(null);
setIsNew(false);
}
}
const update = cloneDeep(actionsSafe);
update[index] = action;
onChange(update);
setEditIndex(null);
};
const onActionAdd = () => {
let update = cloneDeep(actionsSafe);
setEditIndex(update.length);
setIsNew(true);
};
const onActionCancel = (index: number) => {
if (isNew) {
setIsNew(false);
}
setEditIndex(null);
};
const onActionRemove = (index: number) => {
const update = cloneDeep(actionsSafe);
update.splice(index, 1);
onChange(update);
};
const onDragEnd = (result: DropResult) => {
if (!actions || !result.destination) {
return;
}
const update = cloneDeep(actionsSafe);
const action = update[result.source.index];
update.splice(result.source.index, 1);
update.splice(result.destination.index, 0, action);
setActionsSafe(update);
onChange(update);
};
const renderFirstAction = (actionsJSX: ReactNode, key: string) => {
if (showOneClick) {
return (
<div className={styles.oneClickOverlay} key={key}>
<span className={styles.oneClickSpan}>One-click action</span> {actionsJSX}
</div>
);
}
return actionsJSX;
};
return (
<>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sortable-actions" direction="vertical">
{(provided) => (
<div className={styles.wrapper} ref={provided.innerRef} {...provided.droppableProps}>
{actionsSafe.map((action, idx) => {
const key = `${action.title}/${idx}`;
const actionsJSX = (
<div className={styles.itemWrapper} key={key}>
<ActionListItem
key={key}
index={idx}
action={action}
onChange={onActionChange}
onEdit={() => setEditIndex(idx)}
onRemove={() => onActionRemove(idx)}
data={data}
itemKey={key}
/>
</div>
);
if (idx === 0) {
return renderFirstAction(actionsJSX, key);
}
return actionsJSX;
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{isEditing && editIndex !== null && (
<Modal
title="Edit action"
isOpen={true}
closeOnBackdropClick={false}
onDismiss={() => {
onActionCancel(editIndex);
}}
>
<ActionEditorModalContent
index={editIndex}
action={isNew ? defaultActionConfig : actionsSafe[editIndex]}
data={data}
onSave={onActionChange}
onCancel={onActionCancel}
getSuggestions={getSuggestions}
/>
</Modal>
)}
<Button size="sm" icon="plus" onClick={onActionAdd} variant="secondary" className={styles.button}>
Add action
</Button>
</>
);
};
const getActionsInlineEditorStyle = (theme: GrafanaTheme2) => ({
wrapper: css({
marginBottom: theme.spacing(2),
display: 'flex',
flexDirection: 'column',
}),
oneClickOverlay: css({
height: 'auto',
border: `2px dashed ${theme.colors.text.link}`,
fontSize: 10,
color: theme.colors.text.primary,
marginBottom: theme.spacing(1),
}),
oneClickSpan: css({
padding: 10,
// Negates the padding on the span from moving the underlying link
marginBottom: -10,
display: 'inline-block',
}),
itemWrapper: css({
padding: '4px 8px 8px 8px',
}),
button: css({
marginLeft: theme.spacing(1),
}),
});

@ -0,0 +1,111 @@
import { css, cx } from '@emotion/css';
import { Draggable } from '@hello-pangea/dnd';
import { Action, DataFrame, GrafanaTheme2 } from '@grafana/data';
import { Icon } from '@grafana/ui/src/components/Icon/Icon';
import { IconButton } from '@grafana/ui/src/components/IconButton/IconButton';
import { useStyles2 } from '@grafana/ui/src/themes';
export interface ActionsListItemProps {
index: number;
action: Action;
data: DataFrame[];
onChange: (index: number, action: Action) => void;
onEdit: () => void;
onRemove: () => void;
isEditing?: boolean;
itemKey: string;
}
export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: ActionsListItemProps) => {
const styles = useStyles2(getActionListItemStyles);
const { title = '' } = action;
const hasTitle = title.trim() !== '';
return (
<Draggable key={itemKey} draggableId={itemKey} index={index}>
{(provided) => (
<>
<div
className={cx(styles.wrapper, styles.dragRow)}
ref={provided.innerRef}
{...provided.draggableProps}
key={index}
>
<div className={styles.linkDetails}>
<div className={cx(styles.url, !hasTitle && styles.notConfigured)}>
{hasTitle ? title : 'Action title not provided'}
</div>
</div>
<div className={styles.icons}>
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit action title" />
<IconButton name="times" onClick={onRemove} className={styles.icon} tooltip="Remove action title" />
<div className={styles.dragIcon} {...provided.dragHandleProps}>
<Icon name="draggabledots" size="lg" />
</div>
</div>
</div>
</>
)}
</Draggable>
);
};
const getActionListItemStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({
display: 'flex',
flexGrow: 1,
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '5px 0 5px 10px',
borderRadius: theme.shape.radius.default,
background: theme.colors.background.secondary,
gap: 8,
}),
linkDetails: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
}),
errored: css({
color: theme.colors.error.text,
fontStyle: 'italic',
}),
notConfigured: css({
fontStyle: 'italic',
}),
title: css({
color: theme.colors.text.primary,
fontSize: theme.typography.size.sm,
fontWeight: theme.typography.fontWeightMedium,
}),
url: css({
color: theme.colors.text.secondary,
fontSize: theme.typography.size.sm,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: `calc(100% - 100px)`,
}),
dragIcon: css({
cursor: 'grab',
color: theme.colors.text.secondary,
margin: theme.spacing(0, 0.5),
}),
icon: css({
color: theme.colors.text.secondary,
}),
dragRow: css({
position: 'relative',
}),
icons: css({
display: 'flex',
padding: 6,
alignItems: 'center',
gap: 8,
}),
};
};

@ -0,0 +1,130 @@
import { css } from '@emotion/css';
import { useEffect, useState } from 'react';
import { contentTypeOptions, GrafanaTheme2, VariableSuggestion } from '@grafana/data';
import { IconButton } from '@grafana/ui/src/components/IconButton/IconButton';
import { Input } from '@grafana/ui/src/components/Input/Input';
import { Stack } from '@grafana/ui/src/components/Layout/Stack/Stack';
import { Select } from '@grafana/ui/src/components/Select/Select';
import { useStyles2 } from '@grafana/ui/src/themes';
import { SuggestionsInput } from '../transformers/suggestionsInput/SuggestionsInput';
interface Props {
onChange: (v: Array<[string, string]>) => void;
value: Array<[string, string]>;
suggestions: VariableSuggestion[];
contentTypeHeader?: boolean;
}
export const ParamsEditor = ({ value, onChange, suggestions, contentTypeHeader = false }: Props) => {
const styles = useStyles2(getStyles);
const headersContentType = value.find(([key, value]) => key === 'Content-Type');
const [paramName, setParamName] = useState('');
const [paramValue, setParamValue] = useState('');
const [contentTypeParamValue, setContentTypeParamValue] = useState('');
useEffect(() => {
if (contentTypeParamValue !== '') {
setContentTypeParamValue(contentTypeParamValue);
} else if (headersContentType) {
setContentTypeParamValue(headersContentType[1]);
}
}, [contentTypeParamValue, headersContentType]);
// forces re-init of first SuggestionsInput(s), since they are stateful and don't respond to 'value' prop changes to be able to clear them :(
const [entryKey, setEntryKey] = useState(Math.random().toString());
const changeParamValue = (paramValue: string) => {
setParamValue(paramValue);
};
const changeParamName = (paramName: string) => {
setParamName(paramName);
};
const removeParam = (key: string) => () => {
const updatedParams = value.filter((param) => param[0] !== key);
onChange(updatedParams);
};
const addParam = (contentType?: [string, string]) => {
let newParams: Array<[string, string]>;
if (value) {
newParams = value.filter((e) => e[0] !== (contentType ? contentType[0] : paramName));
} else {
newParams = [];
}
newParams.push(contentType ?? [paramName, paramValue]);
newParams.sort((a, b) => a[0].localeCompare(b[0]));
onChange(newParams);
setParamName('');
setParamValue('');
setEntryKey(Math.random().toString());
};
const changeContentTypeParamValue = (value: string) => {
setContentTypeParamValue(value);
addParam(['Content-Type', value]);
};
const isAddParamsDisabled = paramName === '' || paramValue === '';
return (
<div>
<Stack direction="row" key={entryKey}>
<SuggestionsInput
value={paramName}
onChange={changeParamName}
suggestions={suggestions}
placeholder="Key"
style={{ width: 332 }}
/>
<SuggestionsInput
value={paramValue}
onChange={changeParamValue}
suggestions={suggestions}
placeholder="Value"
style={{ width: 332 }}
/>
<IconButton aria-label="add" name="plus-circle" onClick={() => addParam()} disabled={isAddParamsDisabled} />
</Stack>
<Stack direction="column">
{Array.from(value.filter((param) => param[0] !== 'Content-Type') || []).map((entry) => (
<Stack key={entry[0]} direction="row">
<Input disabled value={entry[0]} />
<Input disabled value={entry[1]} />
<IconButton aria-label="delete" onClick={removeParam(entry[0])} name="trash-alt" />
</Stack>
))}
</Stack>
{contentTypeHeader && (
<div className={styles.extraHeader}>
<Stack direction="row">
<Input value={'Content-Type'} disabled />
<Select
onChange={(select) => changeContentTypeParamValue(select.value as string)}
options={contentTypeOptions}
value={contentTypeParamValue}
/>
</Stack>
</div>
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
extraHeader: css({
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
maxWidth: 673,
}),
});

@ -0,0 +1,148 @@
import {
Action,
ActionModel,
AppEvents,
DataContextScopedVar,
DataFrame,
DataLink,
Field,
FieldType,
getFieldDataContextClone,
InterpolateFunction,
ScopedVars,
textUtil,
ValueLinkConfig,
} from '@grafana/data';
import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime';
import { appEvents } from 'app/core/core';
import { HttpRequestMethod } from '../../plugins/panel/canvas/panelcfg.gen';
import { createAbsoluteUrl, RelativeUrl } from '../alerting/unified/utils/url';
/** @internal */
export const getActions = (
frame: DataFrame,
field: Field,
fieldScopedVars: ScopedVars,
replaceVariables: InterpolateFunction,
actions: Action[],
config: ValueLinkConfig
): Array<ActionModel<Field>> => {
if (!actions || actions.length === 0) {
return [];
}
const actionModels = actions.map((action: Action) => {
const dataContext: DataContextScopedVar = getFieldDataContextClone(frame, field, fieldScopedVars);
const actionScopedVars = {
...fieldScopedVars,
__dataContext: dataContext,
};
const boundReplaceVariables: InterpolateFunction = (value, scopedVars, format) => {
return replaceVariables(value, { ...actionScopedVars, ...scopedVars }, format);
};
// We are not displaying reduction result
if (config.valueRowIndex !== undefined && !isNaN(config.valueRowIndex)) {
dataContext.value.rowIndex = config.valueRowIndex;
} else {
dataContext.value.calculatedValue = config.calculatedValue;
}
let actionModel: ActionModel<Field> = { title: '', onClick: (e) => {} };
actionModel = {
title: replaceVariables(action.title || '', actionScopedVars),
onClick: (evt: MouseEvent, origin: Field) => {
buildActionOnClick(action, boundReplaceVariables);
},
};
return actionModel;
});
return actionModels.filter((action): action is ActionModel => !!action);
};
/** @internal */
const buildActionOnClick = (action: Action, replaceVariables: InterpolateFunction) => {
try {
const url = new URL(getUrl(replaceVariables(action.options.url)));
const requestHeaders: Record<string, string> = {};
let request: BackendSrvRequest = {
url: url.toString(),
method: action.options.method,
data: getData(action, replaceVariables),
headers: requestHeaders,
};
if (action.options.headers) {
action.options.headers.forEach(([name, value]) => {
requestHeaders[replaceVariables(name)] = replaceVariables(value);
});
}
if (action.options.queryParams) {
action.options.queryParams?.forEach(([name, value]) => {
url.searchParams.append(replaceVariables(name), replaceVariables(value));
});
request.url = url.toString();
}
requestHeaders['X-Grafana-Action'] = '1';
request.headers = requestHeaders;
getBackendSrv()
.fetch(request)
.subscribe({
error: (error) => {
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
console.error(error);
},
complete: () => {
appEvents.emit(AppEvents.alertSuccess, ['API call was successful']);
},
});
} catch (error) {
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
console.error(error);
return;
}
};
/** @internal */
// @TODO update return type
export const getActionsDefaultField = (dataLinks: DataLink[] = [], actions: Action[] = []) => {
return {
name: 'Default field',
type: FieldType.string,
config: { links: dataLinks, actions: actions },
values: [],
};
};
/** @internal */
const getUrl = (endpoint: string) => {
const isRelativeUrl = endpoint.startsWith('/');
if (isRelativeUrl) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sanitizedRelativeURL = textUtil.sanitizeUrl(endpoint) as RelativeUrl;
endpoint = createAbsoluteUrl(sanitizedRelativeURL, []);
}
return endpoint;
};
/** @internal */
const getData = (action: Action, replaceVariables: InterpolateFunction) => {
let data: string | undefined = action.options.body ? replaceVariables(action.options.body) : '{}';
if (action.options.method === HttpRequestMethod.GET) {
data = undefined;
}
return data;
};

@ -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);
}
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: ['Data links'],
category: [oneClickCategory],
path: 'oneClickMode',
name: 'One-click',
description: 'When enabled, a single click opens the first link',
description: oneClickDescription,
settings: {
options: [
{ value: OneClickMode.Off, label: capitalize(OneClickMode.Off) },
{ value: OneClickMode.Link, label: capitalize(OneClickMode.Link) },
],
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,
});
},
};

Loading…
Cancel
Save