Chore: Data links and Actions components refactor (#100097)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Adela Almasan 2025-02-07 20:03:05 -06:00 committed by GitHub
parent bce05cd48d
commit f5c049012b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 430 additions and 652 deletions

View File

@ -1367,13 +1367,6 @@ exports[`better eslint`] = {
[0, 0, 0, "\'@grafana/ui/src/themes\' import is restricted from being used by a pattern. Import from the public export instead.", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"]
],
"public/app/features/actions/ActionsListItem.tsx:5381": [
[0, 0, 0, "\'@grafana/ui/src/components/Icon/Icon\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
[0, 0, 0, "\'@grafana/ui/src/components/IconButton/IconButton\' import is restricted from being used by a pattern. Import from the public export instead.", "1"],
[0, 0, 0, "\'@grafana/ui/src/themes\' import is restricted from being used by a pattern. Import from the public export instead.", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"]
],
"public/app/features/actions/ParamsEditor.tsx:5381": [
[0, 0, 0, "\'@grafana/ui/src/components/IconButton/IconButton\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
[0, 0, 0, "\'@grafana/ui/src/components/Input/Input\' import is restricted from being used by a pattern. Import from the public export instead.", "1"],

View File

@ -16,6 +16,7 @@ export interface Action {
// once multiple types are valid, usage of this will need to be optional
[ActionType.Fetch]: FetchOptions;
confirmation?: string;
oneClick?: boolean;
}
/**
@ -25,6 +26,7 @@ export interface ActionModel<T = any> {
title: string;
onClick: (event: any, origin?: any) => void;
confirmation?: string;
oneClick?: boolean;
}
interface FetchOptions {

View File

@ -17,6 +17,7 @@ interface DataLinkEditorProps {
value: DataLink;
suggestions: VariableSuggestion[];
onChange: (index: number, link: DataLink, callback?: () => void) => void;
showOneClick?: boolean;
}
const getStyles = (theme: GrafanaTheme2) => ({
@ -30,58 +31,63 @@ const getStyles = (theme: GrafanaTheme2) => ({
}),
});
export const DataLinkEditor = memo(({ index, value, onChange, suggestions, isLast }: DataLinkEditorProps) => {
const styles = useStyles2(getStyles);
export const DataLinkEditor = memo(
({ index, value, onChange, suggestions, isLast, showOneClick = false }: DataLinkEditorProps) => {
const styles = useStyles2(getStyles);
const onUrlChange = (url: string, callback?: () => void) => {
onChange(index, { ...value, url }, callback);
};
const onTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(index, { ...value, title: event.target.value });
};
const onUrlChange = (url: string, callback?: () => void) => {
onChange(index, { ...value, url }, callback);
};
const onOpenInNewTabChanged = () => {
onChange(index, { ...value, targetBlank: !value.targetBlank });
};
const onTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(index, { ...value, title: event.target.value });
};
const onOneClickChanged = () => {
onChange(index, { ...value, oneClick: !value.oneClick });
};
const onOpenInNewTabChanged = () => {
onChange(index, { ...value, targetBlank: !value.targetBlank });
};
return (
<div className={styles.listItem}>
<Field label="Title">
<Input value={value.title} onChange={onTitleChange} placeholder="Show details" />
</Field>
const onOneClickChanged = () => {
onChange(index, { ...value, oneClick: !value.oneClick });
};
<Field label="URL">
<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />
</Field>
return (
<div className={styles.listItem}>
<Field label="Title">
<Input value={value.title} onChange={onTitleChange} placeholder="Show details" />
</Field>
<Field label="Open in new tab">
<Switch value={value.targetBlank || false} onChange={onOpenInNewTabChanged} />
</Field>
<Field label="URL">
<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />
</Field>
<Field
label={t('grafana-ui.data-link-inline-editor.one-click', 'One click')}
description={t(
'grafana-ui.data-link-editor-modal.one-click-description',
'Only one link can have one click enabled at a time'
<Field label="Open in new tab">
<Switch value={value.targetBlank || false} onChange={onOpenInNewTabChanged} />
</Field>
{showOneClick && (
<Field
label={t('grafana-ui.data-link-inline-editor.one-click', 'One click')}
description={t(
'grafana-ui.data-link-editor-modal.one-click-description',
'Only one link can have one click enabled at a time'
)}
>
<Switch value={value.oneClick || false} onChange={onOneClickChanged} />
</Field>
)}
>
<Switch value={value.oneClick || false} onChange={onOneClickChanged} />
</Field>
{isLast && (
<div className={styles.infoText}>
<Trans i18nKey="grafana-ui.data-link-editor.info">
With data links you can reference data variables like series name, labels and values. Type CMD+Space,
CTRL+Space, or $ to open variable suggestions.
</Trans>
</div>
)}
</div>
);
});
{isLast && (
<div className={styles.infoText}>
<Trans i18nKey="grafana-ui.data-link-editor.info">
With data links you can reference data variables like series name, labels and values. Type CMD+Space,
CTRL+Space, or $ to open variable suggestions.
</Trans>
</div>
)}
</div>
);
}
);
DataLinkEditor.displayName = 'DataLinkEditor';

View File

@ -14,14 +14,16 @@ interface DataLinkEditorModalContentProps {
getSuggestions: () => VariableSuggestion[];
onSave: (index: number, ink: DataLink) => void;
onCancel: (index: number) => void;
showOneClick?: boolean;
}
export const DataLinkEditorModalContent = ({
link,
index,
getSuggestions,
onSave,
onCancel,
getSuggestions,
showOneClick,
}: DataLinkEditorModalContentProps) => {
const [dirtyLink, setDirtyLink] = useState(link);
return (
@ -30,10 +32,11 @@ export const DataLinkEditorModalContent = ({
value={dirtyLink}
index={index}
isLast={false}
suggestions={getSuggestions()}
onChange={(index, link) => {
setDirtyLink(link);
}}
suggestions={getSuggestions()}
showOneClick={showOneClick}
/>
<Modal.ButtonRow>
<Button variant="secondary" onClick={() => onCancel(index)} fill="outline">
@ -43,6 +46,7 @@ export const DataLinkEditorModalContent = ({
onClick={() => {
onSave(index, dirtyLink);
}}
disabled={dirtyLink.title.trim() === '' || dirtyLink.url.trim() === ''}
>
<Trans i18nKey="grafana-ui.data-link-editor-modal.save">Save</Trans>
</Button>

View File

@ -1,198 +1,26 @@
import { css } from '@emotion/css';
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
import { cloneDeep } from 'lodash';
import { useEffect, useState } from 'react';
import { DataFrame, DataLink, GrafanaTheme2, VariableSuggestion } from '@grafana/data';
import { useStyles2 } from '../../../themes';
import { Trans } from '../../../utils/i18n';
import { Button } from '../../Button';
import { Modal } from '../../Modal/Modal';
import { DataLink, VariableSuggestion } from '@grafana/data';
import { DataLinkEditorModalContent } from './DataLinkEditorModalContent';
import { DataLinksListItem } from './DataLinksListItem';
import { DataLinksInlineEditorBase, DataLinksInlineEditorBaseProps } from './DataLinksInlineEditorBase';
interface DataLinksInlineEditorProps {
type DataLinksInlineEditorProps = Omit<DataLinksInlineEditorBaseProps<DataLink>, 'children' | 'type' | 'items'> & {
links?: DataLink[];
onChange: (links: DataLink[]) => void;
getSuggestions: () => VariableSuggestion[];
data: DataFrame[];
showOneClick?: boolean;
}
export const DataLinksInlineEditor = ({
links,
onChange,
getSuggestions,
data,
showOneClick = false,
}: DataLinksInlineEditorProps) => {
const [editIndex, setEditIndex] = useState<number | null>(null);
const [isNew, setIsNew] = useState(false);
const [linksSafe, setLinksSafe] = useState<DataLink[]>([]);
useEffect(() => {
setLinksSafe(links ?? []);
}, [links]);
const styles = useStyles2(getDataLinksInlineEditorStyles);
const isEditing = editIndex !== null;
const onDataLinkChange = (index: number, link: DataLink) => {
if (isNew) {
if (link.title.trim() === '' && link.url.trim() === '') {
setIsNew(false);
setEditIndex(null);
return;
} else {
setEditIndex(null);
setIsNew(false);
}
}
if (link.oneClick === true) {
linksSafe.forEach((link) => {
if (link.oneClick) {
link.oneClick = false;
}
});
}
const update = cloneDeep(linksSafe);
update[index] = link;
onChange(update);
setEditIndex(null);
};
const onDataLinkAdd = () => {
let update = cloneDeep(linksSafe);
setEditIndex(update.length);
setIsNew(true);
};
const onDataLinkCancel = (index: number) => {
if (isNew) {
setIsNew(false);
}
setEditIndex(null);
};
const onDataLinkRemove = (index: number) => {
const update = cloneDeep(linksSafe);
update.splice(index, 1);
onChange(update);
};
const onDragEnd = (result: DropResult) => {
if (!links || !result.destination) {
return;
}
const update = cloneDeep(linksSafe);
const link = update[result.source.index];
update.splice(result.source.index, 1);
update.splice(result.destination.index, 0, link);
setLinksSafe(update);
onChange(update);
};
return (
<div className={styles.container}>
{/* one-link placeholder */}
{showOneClick && linksSafe.length > 0 && (
<div className={styles.oneClickOverlay}>
<span className={styles.oneClickSpan}>
<Trans i18nKey="grafana-ui.data-links-inline-editor.one-click-link">One-click link</Trans>
</span>
</div>
)}
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sortable-links" direction="vertical">
{(provided) => (
<div
className={styles.wrapper}
ref={provided.innerRef}
{...provided.droppableProps}
style={{ paddingTop: showOneClick && linksSafe.length > 0 ? '28px' : '0px' }}
>
{linksSafe.map((link, idx) => {
const key = `${link.title}/${idx}`;
return (
<DataLinksListItem
key={key}
index={idx}
link={link}
onChange={onDataLinkChange}
onEdit={() => setEditIndex(idx)}
onRemove={() => onDataLinkRemove(idx)}
data={data}
itemKey={key}
/>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{isEditing && editIndex !== null && (
<Modal
title="Edit link"
isOpen={true}
closeOnBackdropClick={false}
onDismiss={() => {
onDataLinkCancel(editIndex);
}}
>
<DataLinkEditorModalContent
index={editIndex}
link={isNew ? { title: '', url: '' } : linksSafe[editIndex]}
data={data}
onSave={onDataLinkChange}
onCancel={onDataLinkCancel}
getSuggestions={getSuggestions}
/>
</Modal>
)}
<Button size="sm" icon="plus" onClick={onDataLinkAdd} variant="secondary" className={styles.button}>
<Trans i18nKey="grafana-ui.data-links-inline-editor.add-link">Add link</Trans>
</Button>
</div>
);
getSuggestions: () => VariableSuggestion[];
};
const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({
container: css({
position: 'relative',
}),
wrapper: css({
marginBottom: theme.spacing(2),
display: 'flex',
flexDirection: 'column',
}),
oneClickOverlay: css({
border: `2px dashed ${theme.colors.text.link}`,
fontSize: 10,
color: theme.colors.text.primary,
marginBottom: theme.spacing(1),
position: 'absolute',
width: '100%',
height: '92px',
}),
oneClickSpan: css({
padding: 10,
// Negates the padding on the span from moving the underlying link
marginBottom: -10,
display: 'inline-block',
}),
button: css({
marginLeft: theme.spacing(1),
}),
});
export const DataLinksInlineEditor = ({ links, getSuggestions, showOneClick, ...rest }: DataLinksInlineEditorProps) => (
<DataLinksInlineEditorBase<DataLink> type="link" items={links} {...rest}>
{(item, index, onSave, onCancel) => (
<DataLinkEditorModalContent
index={index}
link={item ?? { title: '', url: '' }}
data={rest.data}
onSave={onSave}
onCancel={onCancel}
getSuggestions={getSuggestions}
showOneClick={showOneClick ?? true}
/>
)}
</DataLinksInlineEditorBase>
);

View File

@ -0,0 +1,191 @@
import { css } from '@emotion/css';
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
import { cloneDeep } from 'lodash';
import { useEffect, useState } from 'react';
import { Action, DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../themes';
import { t } from '../../../utils/i18n';
import { Button } from '../../Button';
import { Modal } from '../../Modal/Modal';
import { DataLinksListItemBase } from './DataLinksListItemBase';
export interface DataLinksInlineEditorBaseProps<T extends DataLink | Action> {
type: 'link' | 'action';
items?: T[];
onChange: (items: T[]) => void;
data: DataFrame[];
children: (
item: T,
index: number,
onSave: (index: number, item: T) => void,
onCancel: (index: number) => void
) => React.ReactNode;
}
/** @internal */
export function DataLinksInlineEditorBase<T extends DataLink | Action>({
type,
items,
onChange,
data,
children,
}: DataLinksInlineEditorBaseProps<T>) {
const [editIndex, setEditIndex] = useState<number | null>(null);
const [isNew, setIsNew] = useState(false);
const [itemsSafe, setItemsSafe] = useState<T[]>([]);
useEffect(() => {
setItemsSafe(items ?? []);
}, [items]);
const styles = useStyles2(getDataLinksInlineEditorStyles);
const isEditing = editIndex !== null;
const _onChange = (index: number, item: T) => {
if (isNew) {
const title = item.title;
// @ts-ignore - https://github.com/microsoft/TypeScript/issues/27808
const url = item.url ?? item.fetch?.url ?? '';
if (title.trim() === '' && url.trim() === '') {
setIsNew(false);
setEditIndex(null);
return;
} else {
setEditIndex(null);
setIsNew(false);
}
}
if (item.oneClick === true) {
itemsSafe.forEach((item) => {
if (item.oneClick) {
item.oneClick = false;
}
});
}
const update = cloneDeep(itemsSafe);
update[index] = item;
onChange(update);
setEditIndex(null);
};
const _onCancel = (index: number) => {
if (isNew) {
setIsNew(false);
}
setEditIndex(null);
};
const onDataLinkAdd = () => {
let update = cloneDeep(itemsSafe);
setEditIndex(update.length);
setIsNew(true);
};
const onDataLinkRemove = (index: number) => {
const update = cloneDeep(itemsSafe);
update.splice(index, 1);
onChange(update);
};
const onDragEnd = (result: DropResult) => {
if (items == null || result.destination == null) {
return;
}
const update = cloneDeep(itemsSafe);
const link = update[result.source.index];
update.splice(result.source.index, 1);
update.splice(result.destination.index, 0, link);
setItemsSafe(update);
onChange(update);
};
const getItemText = (action: 'edit' | 'add') => {
let text = '';
switch (type) {
case 'link':
text =
action === 'edit'
? t('grafana-ui.data-links-inline-editor.edit-link', 'Edit link')
: t('grafana-ui.data-links-inline-editor.add-link', 'Add link');
break;
case 'action':
text =
action === 'edit'
? t('grafana-ui.action-editor.inline.edit-action', 'Edit action')
: t('grafana-ui.action-editor.inline.add-action', 'Add action');
break;
}
return text;
};
return (
<div className={styles.container}>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sortable-links" direction="vertical">
{(provided) => (
<div className={styles.wrapper} ref={provided.innerRef} {...provided.droppableProps}>
{itemsSafe.map((item, idx) => {
const key = `${item.title}/${idx}`;
return (
<DataLinksListItemBase<T>
key={key}
index={idx}
item={item}
onChange={_onChange}
onEdit={() => setEditIndex(idx)}
onRemove={() => onDataLinkRemove(idx)}
data={data}
itemKey={key}
/>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{isEditing && editIndex !== null && (
<Modal
title={getItemText(isNew ? 'add' : 'edit')}
isOpen={true}
closeOnBackdropClick={false}
onDismiss={() => {
_onCancel(editIndex);
}}
>
{children(itemsSafe[editIndex], editIndex, _onChange, _onCancel)}
</Modal>
)}
<Button size="sm" icon="plus" onClick={onDataLinkAdd} variant="secondary" className={styles.button}>
{getItemText('add')}
</Button>
</div>
);
}
const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({
container: css({
position: 'relative',
}),
wrapper: css({
marginBottom: theme.spacing(2),
display: 'flex',
flexDirection: 'column',
}),
button: css({
marginLeft: theme.spacing(1),
}),
});

View File

@ -13,7 +13,7 @@ const baseLink = {
function setupTestContext(options: Partial<DataLinksListItemProps>) {
const defaults: DataLinksListItemProps = {
index: 0,
link: baseLink,
item: baseLink,
data: [],
onChange: jest.fn(),
onEdit: jest.fn(),
@ -42,11 +42,11 @@ function setupTestContext(options: Partial<DataLinksListItemProps>) {
describe('DataLinksListItem', () => {
describe('when link has title', () => {
it('then the link title should be visible', () => {
const link = {
const item = {
...baseLink,
title: 'Some Data Link Title',
};
setupTestContext({ link });
setupTestContext({ item });
expect(screen.getByText(/some data link title/i)).toBeInTheDocument();
});
@ -54,62 +54,14 @@ describe('DataLinksListItem', () => {
describe('when link has url', () => {
it('then the link url should be visible', () => {
const link = {
const item = {
...baseLink,
url: 'http://localhost:3000',
};
setupTestContext({ link });
setupTestContext({ item });
expect(screen.getByText(/http:\/\/localhost\:3000/i)).toBeInTheDocument();
expect(screen.getByTitle(/http:\/\/localhost\:3000/i)).toBeInTheDocument();
});
});
describe('when link is missing title', () => {
it('then the link title should be replaced by [Data link title not provided]', () => {
const link = {
...baseLink,
title: undefined as unknown as string,
};
setupTestContext({ link });
expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument();
});
});
describe('when link is missing url', () => {
it('then the link url should be replaced by [Data link url not provided]', () => {
const link = {
...baseLink,
url: undefined as unknown as string,
};
setupTestContext({ link });
expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument();
});
});
describe('when link title is empty', () => {
it('then the link title should be replaced by [Data link title not provided]', () => {
const link = {
...baseLink,
title: ' ',
};
setupTestContext({ link });
expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument();
});
});
describe('when link url is empty', () => {
it('then the link url should be replaced by [Data link url not provided]', () => {
const link = {
...baseLink,
url: ' ',
};
setupTestContext({ link });
expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument();
});
});
});

View File

@ -1,119 +1,6 @@
import { css, cx } from '@emotion/css';
import { Draggable } from '@hello-pangea/dnd';
import { DataLink } from '@grafana/data';
import { DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data';
import { DataLinksListItemBase, DataLinksListItemBaseProps } from './DataLinksListItemBase';
import { useStyles2 } from '../../../themes';
import { t } from '../../../utils/i18n';
import { Badge } from '../../Badge/Badge';
import { Icon } from '../../Icon/Icon';
import { IconButton } from '../../IconButton/IconButton';
export interface DataLinksListItemProps {
index: number;
link: DataLink;
data: DataFrame[];
onChange: (index: number, link: DataLink) => void;
onEdit: () => void;
onRemove: () => void;
isEditing?: boolean;
itemKey: string;
}
export const DataLinksListItem = ({ link, onEdit, onRemove, index, itemKey }: DataLinksListItemProps) => {
const styles = useStyles2(getDataLinkListItemStyles);
const { title = '', url = '', oneClick = false } = link;
const hasTitle = title.trim() !== '';
const hasUrl = url.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, !hasUrl && styles.notConfigured)}>
{hasTitle ? title : 'Data link title not provided'}
</div>
<div className={cx(styles.url, !hasUrl && styles.notConfigured)} title={url}>
{hasUrl ? url : 'Data link url not provided'}
</div>
</div>
<div className={styles.icons}>
{oneClick && (
<Badge
color="blue"
text={t('grafana-ui.data-links-inline-editor.one-click', 'One click')}
tooltip={t('grafana-ui.data-links-inline-editor.one-click-enabled', 'One click enabled')}
/>
)}
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit data link" />
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove data link" />
<div className={styles.dragIcon} {...provided.dragHandleProps}>
<Icon name="draggabledots" size="lg" />
</div>
</div>
</div>
)}
</Draggable>
);
};
const getDataLinkListItemStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({
display: 'flex',
flexGrow: 1,
alignItems: 'center',
justifyContent: 'space-between',
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,
maxWidth: `calc(100% - 100px)`,
}),
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',
}),
dragRow: css({
position: 'relative',
margin: '8px',
}),
icons: css({
display: 'flex',
padding: 6,
alignItems: 'center',
gap: 8,
}),
dragIcon: css({
cursor: 'grab',
color: theme.colors.text.secondary,
margin: theme.spacing(0, 0.5),
}),
icon: css({
color: theme.colors.text.secondary,
}),
};
};
export const DataLinksListItem = DataLinksListItemBase<DataLink>;
export type DataLinksListItemProps = DataLinksListItemBaseProps<DataLink>;

View File

@ -1,27 +1,41 @@
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';
import { Action, DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data';
export interface ActionsListItemProps {
import { useStyles2 } from '../../../themes';
import { t } from '../../../utils/i18n';
import { Badge } from '../../Badge/Badge';
import { Icon } from '../../Icon/Icon';
import { IconButton } from '../../IconButton/IconButton';
export interface DataLinksListItemBaseProps<T extends DataLink | Action> {
index: number;
action: Action;
item: T;
data: DataFrame[];
onChange: (index: number, action: Action) => void;
onChange: (index: number, item: T) => 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;
/** @internal */
export function DataLinksListItemBase<T extends DataLink | Action>({
item,
onEdit,
onRemove,
index,
itemKey,
}: DataLinksListItemBaseProps<T>) {
const styles = useStyles2(getDataLinkListItemStyles);
const { title = '', oneClick = false } = item;
// @ts-ignore - https://github.com/microsoft/TypeScript/issues/27808
const url = item.url ?? item.fetch?.url ?? '';
const hasTitle = title.trim() !== '';
const hasUrl = url.trim() !== '';
return (
<Draggable key={itemKey} draggableId={itemKey} index={index}>
@ -34,12 +48,32 @@ export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: Act
>
<div className={styles.linkDetails}>
<div className={cx(styles.url, !hasTitle && styles.notConfigured)}>
{hasTitle ? title : 'Action title not provided'}
{hasTitle ? title : t('grafana-ui.data-links-inline-editor.title-not-provided', 'Title not provided')}
</div>
<div className={cx(styles.url, !hasUrl && styles.notConfigured)} title={url}>
{hasUrl ? url : t('grafana-ui.data-links-inline-editor.url-not-provided', 'Data link url not provided')}
</div>
</div>
<div className={styles.icons}>
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit action" />
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove action" />
{oneClick && (
<Badge
color="blue"
text={t('grafana-ui.data-links-inline-editor.one-click', 'One click')}
tooltip={t('grafana-ui.data-links-inline-editor.one-click-enabled', 'One click enabled')}
/>
)}
<IconButton
name="pen"
onClick={onEdit}
className={styles.icon}
tooltip={t('grafana-ui.data-links-inline-editor.tooltip-edit', 'Edit')}
/>
<IconButton
name="trash-alt"
onClick={onRemove}
className={styles.icon}
tooltip={t('grafana-ui.data-links-inline-editor.tooltip-remove', 'Remove')}
/>
<div className={styles.dragIcon} {...provided.dragHandleProps}>
<Icon name="draggabledots" size="lg" />
</div>
@ -48,9 +82,9 @@ export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: Act
)}
</Draggable>
);
};
}
const getActionListItemStyles = (theme: GrafanaTheme2) => {
const getDataLinkListItemStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({
display: 'flex',
@ -66,6 +100,7 @@ const getActionListItemStyles = (theme: GrafanaTheme2) => {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
maxWidth: `calc(100% - 100px)`,
}),
errored: css({
color: theme.colors.error.text,
@ -85,15 +120,6 @@ const getActionListItemStyles = (theme: GrafanaTheme2) => {
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',
@ -105,5 +131,13 @@ const getActionListItemStyles = (theme: GrafanaTheme2) => {
alignItems: 'center',
gap: 8,
}),
dragIcon: css({
cursor: 'grab',
color: theme.colors.text.secondary,
margin: theme.spacing(0, 0.5),
}),
icon: css({
color: theme.colors.text.secondary,
}),
};
};

View File

@ -169,6 +169,10 @@ export { MenuGroup, type MenuItemsGroup, type MenuGroupProps } from './Menu/Menu
export { MenuItem, type MenuItemProps } from './Menu/MenuItem';
export { WithContextMenu } from './ContextMenu/WithContextMenu';
export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
export {
DataLinksInlineEditorBase,
type DataLinksInlineEditorBaseProps,
} from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditorBase';
export { DataLinkInput } from './DataLinks/DataLinkInput';
export {
DataLinksContextMenu,

View File

@ -2,6 +2,8 @@ import { css } from '@emotion/css';
import { memo } from 'react';
import { Action, GrafanaTheme2, httpMethodOptions, HttpRequestMethod, VariableSuggestion } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Switch } from '@grafana/ui/';
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';
@ -19,11 +21,12 @@ interface ActionEditorProps {
value: Action;
onChange: (index: number, action: Action) => void;
suggestions: VariableSuggestion[];
showOneClick?: boolean;
}
const LABEL_WIDTH = 13;
export const ActionEditor = memo(({ index, value, onChange, suggestions }: ActionEditorProps) => {
export const ActionEditor = memo(({ index, value, onChange, suggestions, showOneClick }: ActionEditorProps) => {
const styles = useStyles2(getStyles);
const onTitleChange = (title: string) => {
@ -34,6 +37,10 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions }: Actio
onChange(index, { ...value, confirmation });
};
const onOneClickChanged = () => {
onChange(index, { ...value, oneClick: !value.oneClick });
};
const onUrlChange = (url: string) => {
onChange(index, {
...value,
@ -101,6 +108,8 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions }: Actio
value.fetch.method !== HttpRequestMethod.GET &&
value.fetch.headers?.some(([name, value]) => name === 'Content-Type' && value === 'application/json');
const action = config.featureToggles.vizActions ? 'or action' : '';
return (
<div className={styles.listItem}>
<Field label={t('grafana-ui.action-editor.modal.action-title', 'Title')} className={styles.inputField}>
@ -133,6 +142,19 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions }: Actio
/>
</Field>
{showOneClick && (
<Field
label={t('grafana-ui.data-link-inline-editor.one-click', 'One click')}
description={t(
'grafana-ui.action-editor.modal.one-click-description',
'Only one link {{ action }} can have one click enabled at a time',
{ action }
)}
>
<Switch value={value.oneClick || false} onChange={onOneClickChanged} />
</Field>
)}
<InlineFieldRow>
<InlineField
label={t('grafana-ui.action-editor.modal.action-method', 'Method')}

View File

@ -14,6 +14,7 @@ interface ActionEditorModalContentProps {
onSave: (index: number, action: Action) => void;
onCancel: (index: number) => void;
getSuggestions: () => VariableSuggestion[];
showOneClick: boolean;
}
export const ActionEditorModalContent = ({
@ -22,6 +23,7 @@ export const ActionEditorModalContent = ({
onSave,
onCancel,
getSuggestions,
showOneClick,
}: ActionEditorModalContentProps) => {
const [dirtyAction, setDirtyAction] = useState(action);
@ -34,6 +36,7 @@ export const ActionEditorModalContent = ({
setDirtyAction(action);
}}
suggestions={getSuggestions()}
showOneClick={showOneClick}
/>
<Modal.ButtonRow>
<Button variant="secondary" onClick={() => onCancel(index)} fill="outline">

View File

@ -1,193 +1,26 @@
import { css } from '@emotion/css';
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
import { cloneDeep } from 'lodash';
import { 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 { Trans } from 'app/core/internationalization';
import { Action, defaultActionConfig, VariableSuggestion } from '@grafana/data';
import { DataLinksInlineEditorBase, DataLinksInlineEditorBaseProps } from '@grafana/ui';
import { ActionEditorModalContent } from './ActionEditorModalContent';
import { ActionListItem } from './ActionsListItem';
interface ActionsInlineEditorProps {
actions?: Action[];
onChange: (actions: Action[]) => void;
data: DataFrame[];
getSuggestions: () => VariableSuggestion[];
type DataLinksInlineEditorProps = Omit<DataLinksInlineEditorBaseProps<Action>, 'children' | 'type' | 'items'> & {
actions: Action[];
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);
};
return (
<div className={styles.container}>
{/* one-link placeholder */}
{showOneClick && actionsSafe.length > 0 && (
<div className={styles.oneClickOverlay}>
<span className={styles.oneClickSpan}>
<Trans i18nKey="actions-editor.inline.one-click-action">One-click action</Trans>
</span>
</div>
)}
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sortable-actions" direction="vertical">
{(provided) => (
<div
className={styles.wrapper}
ref={provided.innerRef}
{...provided.droppableProps}
style={{ paddingTop: showOneClick && actionsSafe.length > 0 ? '28px' : '0px' }}
>
{actionsSafe.map((action, idx) => {
const key = `${action.title}/${idx}`;
return (
<ActionListItem
key={key}
index={idx}
action={action}
onChange={onActionChange}
onEdit={() => setEditIndex(idx)}
onRemove={() => onActionRemove(idx)}
data={data}
itemKey={key}
/>
);
})}
{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}>
<Trans i18nKey="actions-editor.inline.add-button">Add action</Trans>
</Button>
</div>
);
getSuggestions: () => VariableSuggestion[];
};
const getActionsInlineEditorStyle = (theme: GrafanaTheme2) => ({
container: css({
position: 'relative',
}),
wrapper: css({
marginBottom: theme.spacing(2),
display: 'flex',
flexDirection: 'column',
}),
oneClickOverlay: css({
border: `2px dashed ${theme.colors.text.link}`,
fontSize: 10,
color: theme.colors.text.primary,
marginBottom: theme.spacing(1),
position: 'absolute',
width: '100%',
height: '89px',
}),
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),
}),
});
export const ActionsInlineEditor = ({ actions, getSuggestions, showOneClick, ...rest }: DataLinksInlineEditorProps) => (
<DataLinksInlineEditorBase<Action> type="action" items={actions} {...rest}>
{(item, index, onSave, onCancel) => (
<ActionEditorModalContent
index={index}
action={item ?? defaultActionConfig}
data={rest.data}
onSave={onSave}
onCancel={onCancel}
getSuggestions={getSuggestions}
showOneClick={showOneClick ?? false}
/>
)}
</DataLinksInlineEditorBase>
);

View File

@ -1,20 +1,26 @@
import { StandardEditorProps, OneClickMode, Action, VariableSuggestionsScope } from '@grafana/data';
import { StandardEditorProps, Action, VariableSuggestionsScope } from '@grafana/data';
import { ActionsInlineEditor } from 'app/features/actions/ActionsInlineEditor';
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;
const dataLinks = item.settings?.links || [];
return (
<ActionsInlineEditor
actions={value}
onChange={onChange}
onChange={(actions) => {
if (actions.some(({ oneClick }) => oneClick === true)) {
dataLinks.forEach((link) => {
link.oneClick = false;
});
}
onChange(actions);
}}
getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])}
data={[]}
showOneClick={oneClickMode === OneClickMode.Action}
showOneClick={true}
/>
);
}

View File

@ -1,19 +1,26 @@
import { StandardEditorProps, DataLink, VariableSuggestionsScope, OneClickMode } from '@grafana/data';
import { StandardEditorProps, DataLink, VariableSuggestionsScope } from '@grafana/data';
import { DataLinksInlineEditor } from '@grafana/ui';
import { CanvasElementOptions } from 'app/features/canvas/element';
type Props = StandardEditorProps<DataLink[], CanvasElementOptions>;
export function DataLinksEditor({ value, onChange, item, context }: Props) {
const oneClickMode = item.settings?.oneClickMode;
const actions = item.settings?.actions || [];
return (
<DataLinksInlineEditor
links={value}
onChange={onChange}
onChange={(links) => {
if (links.some(({ oneClick }) => oneClick === true)) {
actions.forEach((action) => {
action.oneClick = false;
});
}
onChange(links);
}}
getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])}
data={[]}
showOneClick={oneClickMode === OneClickMode.Link}
showOneClick={false}
/>
);
}

View File

@ -34,12 +34,6 @@
"save-button": "Save"
}
},
"actions-editor": {
"inline": {
"add-button": "Add action",
"one-click-action": "One-click action"
}
},
"admin": {
"anon-users": {
"not-found": "No anonymous users found."
@ -1574,12 +1568,17 @@
"confirm": "Confirm",
"confirm-action": "Confirm action"
},
"inline": {
"add-action": "Add action",
"edit-action": "Edit action"
},
"modal": {
"action-body": "Body",
"action-method": "Method",
"action-query-params": "Query parameters",
"action-title": "Title",
"action-title-placeholder": "Action title"
"action-title-placeholder": "Action title",
"one-click-description": "Only one link {{ action }} can have one click enabled at a time"
}
},
"auto-save-field": {
@ -1606,9 +1605,13 @@
},
"data-links-inline-editor": {
"add-link": "Add link",
"edit-link": "Edit link",
"one-click": "One click",
"one-click-enabled": "One click enabled",
"one-click-link": "One-click link"
"title-not-provided": "Title not provided",
"tooltip-edit": "Edit",
"tooltip-remove": "Remove",
"url-not-provided": "Data link url not provided"
},
"data-source-http-settings": {
"access-help": "Help <1></1>",

View File

@ -34,12 +34,6 @@
"save-button": "Ŝävę"
}
},
"actions-editor": {
"inline": {
"add-button": "Åđđ äčŧįőʼn",
"one-click-action": "Øʼnę-čľįčĸ äčŧįőʼn"
}
},
"admin": {
"anon-users": {
"not-found": "Ńő äʼnőʼnymőūş ūşęřş ƒőūʼnđ."
@ -1574,12 +1568,17 @@
"confirm": "Cőʼnƒįřm",
"confirm-action": "Cőʼnƒįřm äčŧįőʼn"
},
"inline": {
"add-action": "Åđđ äčŧįőʼn",
"edit-action": "Ēđįŧ äčŧįőʼn"
},
"modal": {
"action-body": "ßőđy",
"action-method": "Męŧĥőđ",
"action-query-params": "Qūęřy päřämęŧęřş",
"action-title": "Ŧįŧľę",
"action-title-placeholder": "Åčŧįőʼn ŧįŧľę"
"action-title-placeholder": "Åčŧįőʼn ŧįŧľę",
"one-click-description": "Øʼnľy őʼnę ľįʼnĸ {{ action }} čäʼn ĥävę őʼnę čľįčĸ ęʼnäþľęđ äŧ ä ŧįmę"
}
},
"auto-save-field": {
@ -1606,9 +1605,13 @@
},
"data-links-inline-editor": {
"add-link": "Åđđ ľįʼnĸ",
"edit-link": "Ēđįŧ ľįʼnĸ",
"one-click": "Øʼnę čľįčĸ",
"one-click-enabled": "Øʼnę čľįčĸ ęʼnäþľęđ",
"one-click-link": "Øʼnę-čľįčĸ ľįʼnĸ"
"title-not-provided": "Ŧįŧľę ʼnőŧ přővįđęđ",
"tooltip-edit": "Ēđįŧ",
"tooltip-remove": "Ŗęmővę",
"url-not-provided": "Đäŧä ľįʼnĸ ūřľ ʼnőŧ přővįđęđ"
},
"data-source-http-settings": {
"access-help": "Ħęľp <1></1>",