Merge branch 'main' into grafana-git-ui-sync

This commit is contained in:
Clarity-89
2025-02-10 08:10:43 +02:00
25 changed files with 617 additions and 692 deletions
-7
View File
@@ -1370,13 +1370,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"],
@@ -15,6 +15,8 @@ export interface Action {
// Currently this is required because there is only one valid type (fetch)
// once multiple types are valid, usage of this will need to be optional
[ActionType.Fetch]: FetchOptions;
confirmation?: string;
oneClick?: boolean;
}
/**
@@ -23,6 +25,8 @@ export interface Action {
export interface ActionModel<T = any> {
title: string;
onClick: (event: any, origin?: any) => void;
confirmation?: string;
oneClick?: boolean;
}
interface FetchOptions {
@@ -1,6 +1,10 @@
import { useState } from 'react';
import { ActionModel, Field } from '@grafana/data';
import { t } from '../../utils/i18n';
import { Button, ButtonProps } from '../Button';
import { ConfirmModal } from '../ConfirmModal/ConfirmModal';
type ActionButtonProps = ButtonProps & {
action: ActionModel<Field>;
@@ -10,9 +14,29 @@ type ActionButtonProps = ButtonProps & {
* @internal
*/
export function ActionButton({ action, ...buttonProps }: ActionButtonProps) {
const [showConfirm, setShowConfirm] = useState(false);
return (
<Button variant="primary" size="sm" onClick={action.onClick} {...buttonProps}>
{action.title}
</Button>
<>
<Button variant="primary" size="sm" onClick={() => setShowConfirm(true)} {...buttonProps}>
{action.title}
</Button>
{showConfirm && (
<ConfirmModal
isOpen={true}
title={t('grafana-ui.action-editor.button.confirm-action', 'Confirm action')}
body={action.confirmation}
confirmText={t('grafana-ui.action-editor.button.confirm', 'Confirm')}
confirmButtonVariant="primary"
onConfirm={() => {
setShowConfirm(false);
action.onClick(new MouseEvent('click'));
}}
onDismiss={() => {
setShowConfirm(false);
}}
/>
)}
</>
);
}
@@ -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';
@@ -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>
@@ -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>
);
@@ -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),
}),
});
@@ -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();
});
});
});
@@ -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>;
@@ -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,
}),
};
};
@@ -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,
+4
View File
@@ -88,3 +88,7 @@ func ToFolderStatusError(err error) k8sErrors.StatusError {
},
}
}
func IsForbidden(err error) bool {
return k8sErrors.IsForbidden(err) || errors.Is(err, dashboards.ErrFolderAccessDenied)
}
+12
View File
@@ -201,6 +201,18 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response
if errors.Is(err, dashboards.ErrFolderNotFound) {
return response.Error(http.StatusNotFound, "Folder not found", err)
}
if apierrors.IsForbidden(err) {
// the dashboard is in a folder the user can't access, so return the dashboard without folder info
err = nil
queryResult = &folder.Folder{
UID: dash.FolderUID,
}
}
if err != nil {
hs.log.Error("Failed to get dashboard folder", "error", err)
return response.Error(http.StatusInternalServerError, "Dashboard folder could not be read", err)
}
meta.FolderUid = queryResult.UID
meta.FolderTitle = queryResult.Title
meta.FolderId = queryResult.ID // nolint:staticcheck
+1 -1
View File
@@ -351,7 +351,7 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
return
}
if parsedResults != nil && len(searchRequest.SortBy) == 0 {
if len(searchRequest.SortBy) == 0 {
// default sort by resource descending ( folders then dashboards ) then title
sort.Slice(parsedResults.Hits, func(i, j int) bool {
return parsedResults.Hits[i].Resource > parsedResults.Hits[j].Resource ||
@@ -14,6 +14,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -1248,26 +1249,13 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb
return nil, err
}
folderNames, err := dr.fetchFolderNames(ctx, query, response.Hits)
if err != nil {
return nil, err
}
finalResults := make([]dashboards.DashboardSearchProjection, len(response.Hits))
// Create a small runtime cache for folders to avoid extra calls to the folder service
foldersMap := make(map[string]*folder.Folder)
serviceCtx, serviceIdent := identity.WithServiceIdentity(ctx, query.OrgId)
for i, hit := range response.Hits {
f, ok := foldersMap[hit.Folder]
if !ok {
// We can get search result where user don't have access to parents. If that happens this thi
// will fail if we call it as the requesting user. To resolve this we call this as the service so we can
// garantuee that we can fetch the parent.
f, err = dr.folderService.Get(serviceCtx, &folder.GetFolderQuery{
UID: &hit.Folder,
OrgID: query.OrgId,
SignedInUser: serviceIdent,
})
if err != nil {
return nil, err
}
foldersMap[hit.Folder] = f
}
result := dashboards.DashboardSearchProjection{
ID: hit.Field.GetNestedInt64(search.DASHBOARD_LEGACY_ID),
UID: hit.Name,
@@ -1276,7 +1264,7 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb
Slug: slugify.Slugify(hit.Title),
IsFolder: false,
FolderUID: hit.Folder,
FolderTitle: f.Title,
FolderTitle: folderNames[hit.Folder],
Tags: hit.Tags,
}
@@ -1293,6 +1281,28 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb
return dr.dashboardStore.FindDashboards(ctx, query)
}
func (dr *DashboardServiceImpl) fetchFolderNames(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery, hits []dashboardv0alpha1.DashboardHit) (map[string]string, error) {
// call this with elevated permissions so we can get folder names where user does not have access
// some dashboards are shared directly with user, but the folder is not accessible via the folder permissions
serviceCtx, serviceIdent := identity.WithServiceIdentity(ctx, query.OrgId)
search := folder.SearchFoldersQuery{
UIDs: getFolderUIDs(hits),
OrgID: query.OrgId,
SignedInUser: serviceIdent,
}
folders, err := dr.folderService.SearchFolders(serviceCtx, search)
if err != nil {
return nil, folder.ErrInternal.Errorf("failed to fetch parent folders: %w", err)
}
folderNames := make(map[string]string)
for _, f := range folders {
folderNames[f.UID] = f.Title
}
return folderNames, nil
}
func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) (model.HitList, error) {
ctx, span := tracer.Start(ctx, "dashboards.service.SearchDashboards")
defer span.End()
@@ -1652,7 +1662,7 @@ func (dr *DashboardServiceImpl) listDashboardsThroughK8s(ctx context.Context, or
return dashboards, nil
}
func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) (*dashboardv0alpha1.SearchResults, error) {
func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) (dashboardv0alpha1.SearchResults, error) {
request := &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Fields: []*resource.Requirement{},
@@ -1777,7 +1787,7 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex
}
if err != nil {
return nil, err
return dashboardv0alpha1.SearchResults{}, err
}
if federate != nil {
@@ -1794,7 +1804,7 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex
res, err := dr.k8sclient.Search(ctx, query.OrgId, request)
if err != nil {
return nil, err
return dashboardv0alpha1.SearchResults{}, err
}
return dashboardsearch.ParseResults(res, 0)
@@ -2083,3 +2093,13 @@ func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, names
return finalObj, nil
}
func getFolderUIDs(hits []dashboardv0alpha1.DashboardHit) []string {
folderSet := map[string]bool{}
for _, hit := range hits {
if hit.Folder != "" && !folderSet[hit.Folder] {
folderSet[hit.Folder] = true
}
}
return maps.Keys(folderSet)
}
@@ -1338,7 +1338,9 @@ func TestSearchDashboards(t *testing.T) {
fakeFolders := foldertest.NewFakeService()
fakeFolders.ExpectedFolder = &folder.Folder{
Title: "testing-folder-1",
UID: "f1",
}
fakeFolders.ExpectedFolders = []*folder.Folder{fakeFolders.ExpectedFolder}
defer fakeStore.AssertExpectations(t)
service := &DashboardServiceImpl{
cfg: setting.NewCfg(),
@@ -1359,6 +1361,7 @@ func TestSearchDashboards(t *testing.T) {
"tag2",
},
FolderTitle: "testing-folder-1",
FolderUID: "f1",
},
{
UID: "uid2",
@@ -1369,6 +1372,7 @@ func TestSearchDashboards(t *testing.T) {
URL: "/d/uid2/dashboard-2",
Tags: []string{},
FolderTitle: "testing-folder-1",
FolderUID: "f1",
},
}
query := dashboards.FindPersistedDashboardsQuery{
@@ -1384,6 +1388,7 @@ func TestSearchDashboards(t *testing.T) {
Title: "Dashboard 1",
Tags: []string{"tag1", "tag2"},
FolderTitle: "testing-folder-1",
FolderUID: "f1",
},
{
UID: "uid2",
@@ -1391,6 +1396,7 @@ func TestSearchDashboards(t *testing.T) {
OrgID: 1,
Title: "Dashboard 2",
FolderTitle: "testing-folder-1",
FolderUID: "f1",
},
}, nil).Once()
result, err := service.SearchDashboards(context.Background(), &query)
@@ -1401,6 +1407,13 @@ func TestSearchDashboards(t *testing.T) {
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service)
expectedFolders := model.HitList{
{
UID: "f1",
Title: "testing-folder-1",
},
}
fakeFolders.ExpectedHitList = expectedFolders
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default")
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
@@ -1426,7 +1439,7 @@ func TestSearchDashboards(t *testing.T) {
},
Cells: [][]byte{
[]byte("Dashboard 1"),
[]byte(""),
[]byte("f1"),
[]byte("[\"tag1\", \"tag2\"]"),
},
},
@@ -1437,7 +1450,7 @@ func TestSearchDashboards(t *testing.T) {
},
Cells: [][]byte{
[]byte("Dashboard 2"),
[]byte(""),
[]byte("f1"),
[]byte(""),
},
},
@@ -36,13 +36,13 @@ var (
}
)
func ParseResults(result *resource.ResourceSearchResponse, offset int64) (*v0alpha1.SearchResults, error) {
func ParseResults(result *resource.ResourceSearchResponse, offset int64) (v0alpha1.SearchResults, error) {
if result == nil {
return nil, nil
return v0alpha1.SearchResults{}, nil
} else if result.Error != nil {
return nil, fmt.Errorf("%d error searching: %s: %s", result.Error.Code, result.Error.Message, result.Error.Details)
return v0alpha1.SearchResults{}, fmt.Errorf("%d error searching: %s: %s", result.Error.Code, result.Error.Message, result.Error.Details)
} else if result.Results == nil {
return nil, nil
return v0alpha1.SearchResults{}, nil
}
titleIDX := 0
@@ -66,7 +66,7 @@ func ParseResults(result *resource.ResourceSearchResponse, offset int64) (*v0alp
}
}
sr := &v0alpha1.SearchResults{
sr := v0alpha1.SearchResults{
Offset: offset,
TotalHits: result.TotalHits,
QueryCost: result.QueryCost,
@@ -80,7 +80,7 @@ func ParseResults(result *resource.ResourceSearchResponse, offset int64) (*v0alp
if _, ok := excludedFields[col.Name]; !ok {
val, err := resource.DecodeCell(col, colIndex, row.Cells[colIndex])
if err != nil {
return nil, err
return v0alpha1.SearchResults{}, err
}
// Some of the dashboard fields come in as int32, but we need to convert them to int64 or else fields.Set() will panic
int32Val, ok := val.(int32)
+63 -6
View File
@@ -2,12 +2,15 @@ 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';
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 { t } from '@grafana/ui/src/utils/i18n';
import { HTMLElementType, SuggestionsInput } from '../transformers/suggestionsInput/SuggestionsInput';
@@ -18,17 +21,26 @@ 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) => {
onChange(index, { ...value, title });
};
const onConfirmationChange = (confirmation: string) => {
onChange(index, { ...value, confirmation });
};
const onOneClickChanged = () => {
onChange(index, { ...value, oneClick: !value.oneClick });
};
const onUrlChange = (url: string) => {
onChange(index, {
...value,
@@ -96,20 +108,59 @@ 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="Title">
<Field label={t('grafana-ui.action-editor.modal.action-title', 'Title')} className={styles.inputField}>
<SuggestionsInput
value={value.title}
onChange={onTitleChange}
suggestions={suggestions}
autoFocus={value.title === ''}
placeholder="Action title"
placeholder={t('grafana-ui.action-editor.modal.action-title-placeholder', 'Action title')}
/>
</Field>
<Field
label={t('grafana-ui.viz-tooltip.actions-confirmation-label', 'Confirmation message')}
description={t(
'grafana-ui.viz-tooltip.actions-confirmation-message',
'Provide a descriptive prompt to confirm or cancel the action.'
)}
className={styles.inputField}
>
<SuggestionsInput
value={value.confirmation}
onChange={onConfirmationChange}
suggestions={suggestions}
placeholder={t(
'grafana-ui.viz-tooltip.actions-confirmation-input-placeholder',
'Are you sure you want to {{ actionTitle }}?',
{ actionTitle: value.title || '... ' }
)}
/>
</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="Method" labelWidth={LABEL_WIDTH} grow={true}>
<InlineField
label={t('grafana-ui.action-editor.modal.action-method', 'Method')}
labelWidth={LABEL_WIDTH}
grow={true}
>
<RadioButtonGroup<HttpRequestMethod>
value={value?.fetch.method}
options={httpMethodOptions}
@@ -130,7 +181,10 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions }: Actio
</InlineField>
</InlineFieldRow>
<Field label="Query parameters" className={styles.fieldGap}>
<Field
label={t('grafana-ui.action-editor.modal.action-query-params', 'Query parameters')}
className={styles.fieldGap}
>
<ParamsEditor value={value?.fetch.queryParams ?? []} onChange={onQueryParamsChange} suggestions={suggestions} />
</Field>
@@ -144,7 +198,7 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions }: Actio
</Field>
{value?.fetch.method !== HttpRequestMethod.GET && (
<Field label="Body">
<Field label={t('grafana-ui.action-editor.modal.action-body', 'Body')} className={styles.inputField}>
<SuggestionsInput
value={value.fetch.body}
onChange={onBodyChange}
@@ -176,6 +230,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
fieldGap: css({
marginTop: theme.spacing(2),
}),
inputField: css({
marginRight: 4,
}),
});
ActionEditor.displayName = 'ActionEditor';
@@ -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">
@@ -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>
);
+8 -3
View File
@@ -50,10 +50,15 @@ export const getActions = (
dataContext.value.calculatedValue = config.calculatedValue;
}
let actionModel: ActionModel<Field> = { title: '', onClick: (e) => {} };
const title = replaceVariables(action.title, actionScopedVars);
const confirmation = replaceVariables(
action.confirmation || `Are you sure you want to ${action.title}?`,
actionScopedVars
);
actionModel = {
title: replaceVariables(action.title || '', actionScopedVars),
const actionModel: ActionModel<Field> = {
title,
confirmation,
onClick: (evt: MouseEvent, origin: Field) => {
buildActionOnClick(action, boundReplaceVariables);
},
@@ -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}
/>
);
}
@@ -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}
/>
);
}
+26 -7
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."
@@ -1569,6 +1563,24 @@
"send-custom-feedback": "Send"
},
"grafana-ui": {
"action-editor": {
"button": {
"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",
"one-click-description": "Only one link {{ action }} can have one click enabled at a time"
}
},
"auto-save-field": {
"saved": "Saved!",
"saving": "Saving <1></1>"
@@ -1593,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>",
@@ -1676,6 +1692,9 @@
"right-axis-indicator": "(right y-axis)"
},
"viz-tooltip": {
"actions-confirmation-input-placeholder": "Are you sure you want to {{ actionTitle }}?",
"actions-confirmation-label": "Confirmation message",
"actions-confirmation-message": "Provide a descriptive prompt to confirm or cancel the action.",
"footer-add-annotation": "Add annotation",
"footer-click-to-navigate": "Click to open {{linkTitle}}"
}
+26 -7
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đ."
@@ -1569,6 +1563,24 @@
"send-custom-feedback": "Ŝęʼnđ"
},
"grafana-ui": {
"action-editor": {
"button": {
"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 ŧįŧľę",
"one-click-description": "Øʼnľy őʼnę ľįʼnĸ {{ action }} čäʼn ĥävę őʼnę čľįčĸ ęʼnäþľęđ äŧ ä ŧįmę"
}
},
"auto-save-field": {
"saved": "Ŝävęđ!",
"saving": "Ŝävįʼnģ <1></1>"
@@ -1593,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>",
@@ -1676,6 +1692,9 @@
"right-axis-indicator": "(řįģĥŧ y-äχįş)"
},
"viz-tooltip": {
"actions-confirmation-input-placeholder": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő {{ actionTitle }}?",
"actions-confirmation-label": "Cőʼnƒįřmäŧįőʼn męşşäģę",
"actions-confirmation-message": "Přővįđę ä đęşčřįpŧįvę přőmpŧ ŧő čőʼnƒįřm őř čäʼnčęľ ŧĥę äčŧįőʼn.",
"footer-add-annotation": "Åđđ äʼnʼnőŧäŧįőʼn",
"footer-click-to-navigate": "Cľįčĸ ŧő őpęʼn {{linkTitle}}"
}