diff --git a/.betterer.results b/.betterer.results index 51f284e84ed..2c2d423f5f0 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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 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 or use t()", "3"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with 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"], diff --git a/packages/grafana-data/src/types/action.ts b/packages/grafana-data/src/types/action.ts index a3cb099ebe8..fc17aa25046 100644 --- a/packages/grafana-data/src/types/action.ts +++ b/packages/grafana-data/src/types/action.ts @@ -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 { title: string; onClick: (event: any, origin?: any) => void; + confirmation?: string; + oneClick?: boolean; } interface FetchOptions { diff --git a/packages/grafana-ui/src/components/Actions/ActionButton.tsx b/packages/grafana-ui/src/components/Actions/ActionButton.tsx index 49b3c82c5c9..ed023601c1f 100644 --- a/packages/grafana-ui/src/components/Actions/ActionButton.tsx +++ b/packages/grafana-ui/src/components/Actions/ActionButton.tsx @@ -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; @@ -10,9 +14,29 @@ type ActionButtonProps = ButtonProps & { * @internal */ export function ActionButton({ action, ...buttonProps }: ActionButtonProps) { + const [showConfirm, setShowConfirm] = useState(false); + return ( - + <> + + {showConfirm && ( + { + setShowConfirm(false); + action.onClick(new MouseEvent('click')); + }} + onDismiss={() => { + setShowConfirm(false); + }} + /> + )} + ); } diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx index e87d01fbea2..8ff7535790c 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx @@ -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) => { - 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) => { + onChange(index, { ...value, title: event.target.value }); + }; - const onOneClickChanged = () => { - onChange(index, { ...value, oneClick: !value.oneClick }); - }; + const onOpenInNewTabChanged = () => { + onChange(index, { ...value, targetBlank: !value.targetBlank }); + }; - return ( -
- - - + const onOneClickChanged = () => { + onChange(index, { ...value, oneClick: !value.oneClick }); + }; - - - + return ( +
+ + + - - - + + + - + + + + {showOneClick && ( + + + )} - > - - - {isLast && ( -
- - With data links you can reference data variables like series name, labels and values. Type CMD+Space, - CTRL+Space, or $ to open variable suggestions. - -
- )} -
- ); -}); + {isLast && ( +
+ + With data links you can reference data variables like series name, labels and values. Type CMD+Space, + CTRL+Space, or $ to open variable suggestions. + +
+ )} +
+ ); + } +); DataLinkEditor.displayName = 'DataLinkEditor'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx index 5348251f1f2..02287da7215 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx @@ -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} /> diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx index fa4e61589f3..99b15ea1697 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx @@ -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, '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(null); - const [isNew, setIsNew] = useState(false); - - const [linksSafe, setLinksSafe] = useState([]); - - 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 ( -
- {/* one-link placeholder */} - {showOneClick && linksSafe.length > 0 && ( -
- - One-click link - -
- )} - - - - {(provided) => ( -
0 ? '28px' : '0px' }} - > - {linksSafe.map((link, idx) => { - const key = `${link.title}/${idx}`; - return ( - setEditIndex(idx)} - onRemove={() => onDataLinkRemove(idx)} - data={data} - itemKey={key} - /> - ); - })} - {provided.placeholder} -
- )} -
-
- - {isEditing && editIndex !== null && ( - { - onDataLinkCancel(editIndex); - }} - > - - - )} - - -
- ); + 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) => ( + type="link" items={links} {...rest}> + {(item, index, onSave, onCancel) => ( + + )} + +); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditorBase.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditorBase.tsx new file mode 100644 index 00000000000..a5f66c6d0c3 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditorBase.tsx @@ -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 { + 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({ + type, + items, + onChange, + data, + children, +}: DataLinksInlineEditorBaseProps) { + const [editIndex, setEditIndex] = useState(null); + const [isNew, setIsNew] = useState(false); + + const [itemsSafe, setItemsSafe] = useState([]); + + 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 ( +
+ + + {(provided) => ( +
+ {itemsSafe.map((item, idx) => { + const key = `${item.title}/${idx}`; + return ( + + key={key} + index={idx} + item={item} + onChange={_onChange} + onEdit={() => setEditIndex(idx)} + onRemove={() => onDataLinkRemove(idx)} + data={data} + itemKey={key} + /> + ); + })} + {provided.placeholder} +
+ )} +
+
+ + {isEditing && editIndex !== null && ( + { + _onCancel(editIndex); + }} + > + {children(itemsSafe[editIndex], editIndex, _onChange, _onCancel)} + + )} + + +
+ ); +} + +const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({ + container: css({ + position: 'relative', + }), + wrapper: css({ + marginBottom: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + }), + button: css({ + marginLeft: theme.spacing(1), + }), +}); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx index b44576d53f7..d74f11e09f8 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx @@ -13,7 +13,7 @@ const baseLink = { function setupTestContext(options: Partial) { const defaults: DataLinksListItemProps = { index: 0, - link: baseLink, + item: baseLink, data: [], onChange: jest.fn(), onEdit: jest.fn(), @@ -42,11 +42,11 @@ function setupTestContext(options: Partial) { 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(); - }); - }); }); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx index 9bddc35da71..ce8fbe3d95d 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx @@ -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 ( - - {(provided) => ( -
-
-
- {hasTitle ? title : 'Data link title not provided'} -
-
- {hasUrl ? url : 'Data link url not provided'} -
-
-
- {oneClick && ( - - )} - - -
- -
-
-
- )} -
- ); -}; - -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; +export type DataLinksListItemProps = DataLinksListItemBaseProps; diff --git a/public/app/features/actions/ActionsListItem.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItemBase.tsx similarity index 52% rename from public/app/features/actions/ActionsListItem.tsx rename to packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItemBase.tsx index 6dd9cebf58f..6ddbc97e994 100644 --- a/public/app/features/actions/ActionsListItem.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItemBase.tsx @@ -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 { 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({ + item, + onEdit, + onRemove, + index, + itemKey, +}: DataLinksListItemBaseProps) { + 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 ( @@ -34,12 +48,32 @@ export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: Act >
- {hasTitle ? title : 'Action title not provided'} + {hasTitle ? title : t('grafana-ui.data-links-inline-editor.title-not-provided', 'Title not provided')} +
+
+ {hasUrl ? url : t('grafana-ui.data-links-inline-editor.url-not-provided', 'Data link url not provided')}
- - + {oneClick && ( + + )} + +
@@ -48,9 +82,9 @@ export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: Act )} ); -}; +} -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, + }), }; }; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 900190b9acf..8296a00cb70 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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, diff --git a/pkg/api/apierrors/folder.go b/pkg/api/apierrors/folder.go index 531636bc9c9..b29d5000fad 100644 --- a/pkg/api/apierrors/folder.go +++ b/pkg/api/apierrors/folder.go @@ -88,3 +88,7 @@ func ToFolderStatusError(err error) k8sErrors.StatusError { }, } } + +func IsForbidden(err error) bool { + return k8sErrors.IsForbidden(err) || errors.Is(err, dashboards.ErrFolderAccessDenied) +} diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 5079d7d9877..013dbebb5eb 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -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 diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 111b872cecc..824243692a1 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -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 || diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 95d6c8af652..9508f9711e6 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -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) +} diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 5e9167c1f50..ccfa8f9e3a3 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -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(""), }, }, diff --git a/pkg/services/dashboards/service/search/search.go b/pkg/services/dashboards/service/search/search.go index 6165dfe0237..c0f3a5598f5 100644 --- a/pkg/services/dashboards/service/search/search.go +++ b/pkg/services/dashboards/service/search/search.go @@ -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) diff --git a/public/app/features/actions/ActionEditor.tsx b/public/app/features/actions/ActionEditor.tsx index fc7b006a8fc..4194ab2680c 100644 --- a/public/app/features/actions/ActionEditor.tsx +++ b/public/app/features/actions/ActionEditor.tsx @@ -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 (
- + + + + + + {showOneClick && ( + + + + )} + - + value={value?.fetch.method} options={httpMethodOptions} @@ -130,7 +181,10 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions }: Actio - + @@ -144,7 +198,7 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions }: Actio {value?.fetch.method !== HttpRequestMethod.GET && ( - + ({ fieldGap: css({ marginTop: theme.spacing(2), }), + inputField: css({ + marginRight: 4, + }), }); ActionEditor.displayName = 'ActionEditor'; diff --git a/public/app/features/actions/ActionEditorModalContent.tsx b/public/app/features/actions/ActionEditorModalContent.tsx index 54de7277d61..19a52898042 100644 --- a/public/app/features/actions/ActionEditorModalContent.tsx +++ b/public/app/features/actions/ActionEditorModalContent.tsx @@ -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} /> -
- ); + 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) => ( + type="action" items={actions} {...rest}> + {(item, index, onSave, onCancel) => ( + + )} + +); diff --git a/public/app/features/actions/utils.ts b/public/app/features/actions/utils.ts index f33fa82eb9a..29751c4f4e3 100644 --- a/public/app/features/actions/utils.ts +++ b/public/app/features/actions/utils.ts @@ -50,10 +50,15 @@ export const getActions = ( dataContext.value.calculatedValue = config.calculatedValue; } - let actionModel: ActionModel = { 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 = { + title, + confirmation, onClick: (evt: MouseEvent, origin: Field) => { buildActionOnClick(action, boundReplaceVariables); }, diff --git a/public/app/plugins/panel/canvas/editor/element/ActionsEditor.tsx b/public/app/plugins/panel/canvas/editor/element/ActionsEditor.tsx index 06613ffe843..4928c7c3368 100644 --- a/public/app/plugins/panel/canvas/editor/element/ActionsEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/element/ActionsEditor.tsx @@ -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; export function ActionsEditor({ value, onChange, item, context }: Props) { - const oneClickMode = item.settings?.oneClickMode; + const dataLinks = item.settings?.links || []; return ( { + 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} /> ); } diff --git a/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx b/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx index 95779a4b7a1..9849b35d368 100644 --- a/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx @@ -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; export function DataLinksEditor({ value, onChange, item, context }: Props) { - const oneClickMode = item.settings?.oneClickMode; + const actions = item.settings?.actions || []; return ( { + 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} /> ); } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a313559321e..c739515378a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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>" @@ -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>", @@ -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}}" } diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 69a34972f6b..4c73778c894 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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>" @@ -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>", @@ -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}}" }