Merge branch 'main' into grafana-git-ui-sync
This commit is contained in:
@@ -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';
|
||||
|
||||
+6
-2
@@ -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>
|
||||
|
||||
+19
-191
@@ -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>
|
||||
);
|
||||
|
||||
+191
@@ -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),
|
||||
}),
|
||||
});
|
||||
+5
-53
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+4
-117
@@ -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>;
|
||||
|
||||
+58
-24
@@ -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,
|
||||
|
||||
@@ -88,3 +88,7 @@ func ToFolderStatusError(err error) k8sErrors.StatusError {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func IsForbidden(err error) bool {
|
||||
return k8sErrors.IsForbidden(err) || errors.Is(err, dashboards.ErrFolderAccessDenied)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user