diff --git a/docs/sources/features/panels/graph.md b/docs/sources/features/panels/graph.md index 64f31c1e668..95a07da6cc2 100644 --- a/docs/sources/features/panels/graph.md +++ b/docs/sources/features/panels/graph.md @@ -35,20 +35,35 @@ The general tab allows customization of a panel's appearance and menu options. ### Repeat Repeat a panel for each value of a variable. Repeating panels are described in more detail [here]({{< relref "../../reference/templating.md#repeating-panels" >}}). -### Drilldown / detail link +### Data link -The drilldown section allows adding dynamic links to the panel that can link to other dashboards -or URLs. +Data link in graph settings allows adding dynamic links to the visualization. Those links can link to either other dashboard or to an external URL. -Each link has a title, a type and params. A link can be either a ``dashboard`` or ``absolute`` links. -If it is a dashboard link, the `dashboard` value must be the name of a dashboard. If it is an -`absolute` link, the URL is the URL to the link. +{{< docs-imagebox img="/img/docs/data_link.png" max-width= "800px" >}} -``params`` allows adding additional URL params to the links. The format is the ``name=value`` with -multiple params separated by ``&``. Template variables can be added as values using ``$myvar``. +Data link is defined by title, url and a setting whether or not it should be opened in a new window. -When linking to another dashboard that uses template variables, you can use ``var-myvar=value`` to -populate the template variable to a desired value from the link. +**Title** is a human readable label for the link that will be displayed in the UI. The link itself is accessible in the graph's context menu when user **clicks on a single data point**: + +{{< docs-imagebox img="/img/docs/data_link_tooltip.png" max-width= "800px" >}} + +**URL** field allows the URL configuration for a given link. Apart from regular query params it also supports built-in variables and dashboard variables that you can choose from +available suggestions: + +{{< docs-imagebox img="/img/docs/data_link_typeahead.png" max-width= "800px" >}} + + +Available built-in variables are: + +1. ``__all_variables`` - will add all current dashboard's variables to the URL +2. ``__url_time_range`` - will add current dashboard's time range to the URL (i.e. ``?from=now-6h&to=now``) +3. ``__series_name`` - will add series name as a query param in the URL (i.e. ``?series=B-series``) +4. ``__value_time`` - will add datapoint's timestamp (Unix ms epoch) to the URL (i.e. ``?time=1560268814105``) + + +#### Template variables in data links +When linking to another dashboard that uses template variables, you can use ``var-myvar=${myvar}`` syntax (where ``myvar`` is a name of template variable) +to use current dashboard's variable value. ## Metrics diff --git a/packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts b/packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts index fb4e454d2f9..5f0424e0510 100644 --- a/packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts +++ b/packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts @@ -1,4 +1,4 @@ -import deprecationWarning from '../../utils/deprecationWarning'; +import { deprecationWarning } from '../../utils/deprecationWarning'; import { ColorPickerProps } from './ColorPickerPopover'; export const warnAboutColorPickerPropsDeprecation = (componentName: string, props: ColorPickerProps) => { diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 00000000000..d44e2e28aff --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,261 @@ +import React, { useContext, useRef } from 'react'; +import { css, cx } from 'emotion'; +import useClickAway from 'react-use/lib/useClickAway'; +import { GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index'; +import { Portal, List } from '../index'; + +export interface ContextMenuItem { + label: string; + target?: string; + icon?: string; + url?: string; + onClick?: (event?: React.SyntheticEvent) => void; + group?: string; +} + +export interface ContextMenuGroup { + label?: string; + items: ContextMenuItem[]; +} +export interface ContextMenuProps { + x: number; + y: number; + onClose: () => void; + items?: ContextMenuGroup[]; + renderHeader?: () => JSX.Element; +} + +const getContextMenuStyles = (theme: GrafanaTheme) => { + const linkColor = selectThemeVariant( + { + light: theme.colors.dark2, + dark: theme.colors.text, + }, + theme.type + ); + const linkColorHover = selectThemeVariant( + { + light: theme.colors.link, + dark: theme.colors.white, + }, + theme.type + ); + const wrapperBg = selectThemeVariant( + { + light: theme.colors.gray7, + dark: theme.colors.dark2, + }, + theme.type + ); + const wrapperShadow = selectThemeVariant( + { + light: theme.colors.gray3, + dark: theme.colors.black, + }, + theme.type + ); + const itemColor = selectThemeVariant( + { + light: theme.colors.black, + dark: theme.colors.white, + }, + theme.type + ); + + const groupLabelColor = selectThemeVariant( + { + light: theme.colors.gray1, + dark: theme.colors.textWeak, + }, + theme.type + ); + + const itemBgHover = selectThemeVariant( + { + light: theme.colors.gray5, + dark: theme.colors.dark7, + }, + theme.type + ); + const headerBg = selectThemeVariant( + { + light: theme.colors.white, + dark: theme.colors.dark1, + }, + theme.type + ); + const headerSeparator = selectThemeVariant( + { + light: theme.colors.white, + dark: theme.colors.dark7, + }, + theme.type + ); + + return { + header: css` + padding: 4px; + border-bottom: 1px solid ${headerSeparator}; + background: ${headerBg}; + margin-bottom: ${theme.spacing.xs}; + border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0; + `, + wrapper: css` + background: ${wrapperBg}; + z-index: 1; + box-shadow: 0 2px 5px 0 ${wrapperShadow}; + min-width: 200px; + border-radius: ${theme.border.radius.sm}; + `, + link: css` + color: ${linkColor}; + display: flex; + cursor: pointer; + &:hover { + color: ${linkColorHover}; + text-decoration: none; + } + `, + item: css` + background: none; + padding: 4px 8px; + color: ${itemColor}; + border-left: 2px solid transparent; + cursor: pointer; + &:hover { + background: ${itemBgHover}; + border-image: linear-gradient(rgba(255, 213, 0, 1) 0%, rgba(255, 68, 0, 1) 99%, rgba(255, 68, 0, 1) 100%); + border-image-slice: 1; + } + `, + groupLabel: css` + color: ${groupLabelColor}; + font-size: ${theme.typography.size.sm}; + line-height: ${theme.typography.lineHeight.lg}; + padding: ${theme.spacing.xs} ${theme.spacing.sm}; + `, + icon: css` + opacity: 0.7; + width: 12px; + height: 12px; + display: inline-block; + margin-right: 10px; + color: ${theme.colors.linkDisabled}; + position: relative; + top: 4px; + `, + }; +}; + +export const ContextMenu: React.FC = React.memo(({ x, y, onClose, items, renderHeader }) => { + const theme = useContext(ThemeContext); + const menuRef = useRef(null); + useClickAway(menuRef, () => { + if (onClose) { + onClose(); + } + }); + + const styles = getContextMenuStyles(theme); + + return ( + +
+ {renderHeader &&
{renderHeader()}
} + { + return ( + <> + + + ); + }} + /> +
+
+ ); +}); + +interface ContextMenuItemProps { + label: string; + icon?: string; + url?: string; + target?: string; + onClick?: (e: React.MouseEvent) => void; + className?: string; +} + +const ContextMenuItem: React.FC = React.memo( + ({ url, icon, label, target, onClick, className }) => { + const theme = useContext(ThemeContext); + const styles = getContextMenuStyles(theme); + return ( +
+ { + if (onClick) { + onClick(e); + } + }} + > + {icon && } {label} + +
+ ); + } +); + +interface ContextMenuGroupProps { + group: ContextMenuGroup; + onItemClick?: () => void; +} + +const ContextMenuGroup: React.FC = ({ group, onItemClick }) => { + const theme = useContext(ThemeContext); + const styles = getContextMenuStyles(theme); + + if (group.items.length === 0) { + return null; + } + + return ( +
+ {group.label &&
{group.label}
} + { + return ( + ) => { + if (item.onClick) { + item.onClick(e); + } + + if (onItemClick) { + onItemClick(); + } + }} + /> + ); + }} + /> +
+ ); +}; +ContextMenu.displayName = 'ContextMenu'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx new file mode 100644 index 00000000000..e1d47724b47 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx @@ -0,0 +1,85 @@ +import React, { useState, ChangeEvent, useContext } from 'react'; +import { DataLink } from '../../index'; +import { FormField, Switch } from '../index'; +import { VariableSuggestion } from './DataLinkSuggestions'; +import { css, cx } from 'emotion'; +import { ThemeContext } from '../../themes/index'; +import { DataLinkInput } from './DataLinkInput'; + +interface DataLinkEditorProps { + index: number; + value: DataLink; + suggestions: VariableSuggestion[]; + onChange: (index: number, link: DataLink) => void; + onRemove: (link: DataLink) => void; +} + +export const DataLinkEditor: React.FC = React.memo( + ({ index, value, onChange, onRemove, suggestions }) => { + const theme = useContext(ThemeContext); + const [title, setTitle] = useState(value.title); + + const onUrlChange = (url: string) => { + onChange(index, { ...value, url }); + }; + const onTitleChange = (event: ChangeEvent) => { + setTitle(event.target.value); + }; + + const onTitleBlur = () => { + onChange(index, { ...value, title: title }); + }; + + const onRemoveClick = () => { + onRemove(value); + }; + + const onOpenInNewTabChanged = () => { + onChange(index, { ...value, targetBlank: !value.targetBlank }); + }; + + return ( +
* { + margin-right: ${theme.spacing.xs}; + &:last-child { + margin-right: 0; + } + } + ` + )} + > + + + } + className={css` + width: 100%; + `} + /> + + + +
+ +
+
+ ); + } +); + +DataLinkEditor.displayName = 'DataLinkEditor'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx new file mode 100644 index 00000000000..08d466c35ae --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -0,0 +1,200 @@ +import React, { useState, useMemo, useCallback, useContext } from 'react'; +import { VariableSuggestion, VariableOrigin, DataLinkSuggestions } from './DataLinkSuggestions'; +import { makeValue, ThemeContext } from '../../index'; +import { SelectionReference } from './SelectionReference'; +import { Portal } from '../index'; +// @ts-ignore +import { Editor } from 'slate-react'; +// @ts-ignore +import { Value, Change, Document } from 'slate'; +// @ts-ignore +import Plain from 'slate-plain-serializer'; +import { Popper as ReactPopper } from 'react-popper'; +import useDebounce from 'react-use/lib/useDebounce'; +import { css, cx } from 'emotion'; +// @ts-ignore +import PluginPrism from 'slate-prism'; + +interface DataLinkInputProps { + value: string; + onChange: (url: string) => void; + suggestions: VariableSuggestion[]; +} + +const plugins = [ + PluginPrism({ + onlyIn: (node: any) => node.type === 'code_block', + getSyntax: () => 'links', + }), +]; + +export const DataLinkInput: React.FC = ({ value, onChange, suggestions }) => { + const theme = useContext(ThemeContext); + const [showingSuggestions, setShowingSuggestions] = useState(false); + const [suggestionsIndex, setSuggestionsIndex] = useState(0); + const [usedSuggestions, setUsedSuggestions] = useState( + suggestions.filter(suggestion => { + return value.indexOf(suggestion.value) > -1; + }) + ); + // Using any here as TS has problem pickung up `change` method existance on Value + // According to code and documentation `change` is an instance method on Value in slate 0.33.8 that we use + // https://github.com/ianstormtaylor/slate/blob/slate%400.33.8/docs/reference/slate/value.md#change + const [linkUrl, setLinkUrl] = useState(makeValue(value)); + + const getStyles = useCallback(() => { + return { + editor: css` + .token.builtInVariable { + color: ${theme.colors.queryGreen}; + } + .token.variable { + color: ${theme.colors.queryKeyword}; + } + `, + }; + }, [theme]); + + const currentSuggestions = useMemo( + () => + suggestions.filter(suggestion => { + return usedSuggestions.map(s => s.value).indexOf(suggestion.value) === -1; + }), + [usedSuggestions, suggestions] + ); + + // SelectionReference is used to position the variables suggestion relatively to current DOM selection + const selectionRef = useMemo(() => new SelectionReference(), [setShowingSuggestions]); + + // Keep track of variables that has been used already + const updateUsedSuggestions = () => { + const currentLink = Plain.serialize(linkUrl); + const next = usedSuggestions.filter(suggestion => { + return currentLink.indexOf(suggestion.value) > -1; + }); + if (next.length !== usedSuggestions.length) { + setUsedSuggestions(next); + } + }; + + useDebounce(updateUsedSuggestions, 500, [linkUrl]); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Backspace') { + setShowingSuggestions(false); + setSuggestionsIndex(0); + } + + if (event.key === 'Enter') { + if (showingSuggestions) { + onVariableSelect(currentSuggestions[suggestionsIndex]); + } + } + + if (showingSuggestions) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + setSuggestionsIndex(index => { + return (index + 1) % currentSuggestions.length; + }); + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + setSuggestionsIndex(index => { + const nextIndex = index - 1 < 0 ? currentSuggestions.length - 1 : (index - 1) % currentSuggestions.length; + return nextIndex; + }); + } + } + + if (event.key === '?' || event.key === '&' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) { + setShowingSuggestions(true); + } + + if (event.key === 'Backspace') { + // @ts-ignore + return; + } else { + return true; + } + }; + + const onUrlChange = ({ value }: Change) => { + setLinkUrl(value); + }; + + const onUrlBlur = () => { + onChange(Plain.serialize(linkUrl)); + }; + + const onVariableSelect = (item: VariableSuggestion) => { + const includeDollarSign = Plain.serialize(linkUrl).slice(-1) !== '$'; + + const change = linkUrl.change(); + + if (item.origin === VariableOrigin.BuiltIn) { + change.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`); + } else { + change.insertText(`var-${item.value}=$\{${item.value}}`); + } + + setLinkUrl(change.value); + setShowingSuggestions(false); + setUsedSuggestions((previous: VariableSuggestion[]) => { + return [...previous, item]; + }); + setSuggestionsIndex(0); + onChange(Plain.serialize(change.value)); + }; + return ( +
+
+ {showingSuggestions && ( + + + {({ ref, style, placement }) => { + return ( +
+ setShowingSuggestions(false)} + activeIndex={suggestionsIndex} + /> +
+ ); + }} +
+
+ )} + +
+
+ ); +}; + +DataLinkInput.displayName = 'DataLinkInput'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx new file mode 100644 index 00000000000..0fc14f2b199 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx @@ -0,0 +1,190 @@ +import { GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index'; +import { css, cx } from 'emotion'; +import React, { useRef, useContext, useMemo } from 'react'; +import useClickAway from 'react-use/lib/useClickAway'; +import { List } from '../index'; + +export enum VariableOrigin { + BuiltIn = 'builtin', + Template = 'template', +} + +export interface VariableSuggestion { + value: string; + documentation?: string; + origin: VariableOrigin; +} + +interface DataLinkSuggestionsProps { + suggestions: VariableSuggestion[]; + activeIndex: number; + onSuggestionSelect: (suggestion: VariableSuggestion) => void; + onClose?: () => void; +} + +const getStyles = (theme: GrafanaTheme) => { + const wrapperBg = selectThemeVariant( + { + light: theme.colors.white, + dark: theme.colors.dark2, + }, + theme.type + ); + + const wrapperShadow = selectThemeVariant( + { + light: theme.colors.gray5, + dark: theme.colors.black, + }, + theme.type + ); + + const itemColor = selectThemeVariant( + { + light: theme.colors.black, + dark: theme.colors.white, + }, + theme.type + ); + + const itemDocsColor = selectThemeVariant( + { + light: theme.colors.dark3, + dark: theme.colors.gray2, + }, + theme.type + ); + + const itemBgHover = selectThemeVariant( + { + light: theme.colors.gray5, + dark: theme.colors.dark7, + }, + theme.type + ); + + const itemBgActive = selectThemeVariant( + { + light: theme.colors.gray6, + dark: theme.colors.dark9, + }, + theme.type + ); + + return { + wrapper: css` + background: ${wrapperBg}; + z-index: 1; + width: 200px; + box-shadow: 0 5px 10px 0 ${wrapperShadow}; + `, + item: css` + background: none; + padding: 4px 8px; + color: ${itemColor}; + cursor: pointer; + &:hover { + background: ${itemBgHover}; + } + `, + label: css` + color: ${theme.colors.textWeak}; + font-size: ${theme.typography.size.sm}; + line-height: ${theme.typography.lineHeight.lg}; + padding: ${theme.spacing.sm}; + `, + activeItem: css` + background: ${itemBgActive}; + &:hover { + background: ${itemBgActive}; + } + `, + itemValue: css` + font-family: ${theme.typography.fontFamily.monospace}; + `, + itemDocs: css` + margin-top: ${theme.spacing.xs}; + color: ${itemDocsColor}; + font-size: ${theme.typography.size.sm}; + `, + }; +}; + +export const DataLinkSuggestions: React.FC = ({ suggestions, ...otherProps }) => { + const ref = useRef(null); + const theme = useContext(ThemeContext); + useClickAway(ref, () => { + if (otherProps.onClose) { + otherProps.onClose(); + } + }); + + const templateSuggestions = useMemo(() => { + return suggestions.filter(suggestion => suggestion.origin === VariableOrigin.Template); + }, [suggestions]); + + const builtInSuggestions = useMemo(() => { + return suggestions.filter(suggestion => suggestion.origin === VariableOrigin.BuiltIn); + }, [suggestions]); + + const styles = getStyles(theme); + return ( +
+ {templateSuggestions.length > 0 && ( + + )} + {builtInSuggestions.length > 0 && ( + + )} +
+ ); +}; + +DataLinkSuggestions.displayName = 'DataLinkSuggestions'; + +interface DataLinkSuggestionsListProps extends DataLinkSuggestionsProps { + label: string; + activeIndexOffset: number; +} + +const DataLinkSuggestionsList: React.FC = React.memo( + ({ activeIndex, activeIndexOffset, label, onClose, onSuggestionSelect, suggestions }) => { + const theme = useContext(ThemeContext); + const styles = getStyles(theme); + + return ( + <> +
{label}
+ { + return ( +
{ + onSuggestionSelect(item); + }} + > +
{item.value}
+ {item.documentation &&
{item.documentation}
} +
+ ); + }} + /> + + ); + } +); + +DataLinkSuggestionsList.displayName = 'DataLinkSuggestionsList'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksEditor.tsx new file mode 100644 index 00000000000..8a630f0fd34 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksEditor.tsx @@ -0,0 +1,77 @@ +// Libraries +import React, { FC, useContext } from 'react'; +// @ts-ignore +import Prism from 'prismjs'; +// Components +import { css } from 'emotion'; +import { DataLink, ThemeContext } from '../../index'; +import { Button } from '../index'; +import { DataLinkEditor } from './DataLinkEditor'; +import { VariableSuggestion } from './DataLinkSuggestions'; + +interface DataLinksEditorProps { + value: DataLink[]; + onChange: (links: DataLink[]) => void; + suggestions: VariableSuggestion[]; + maxLinks?: number; +} + +Prism.languages['links'] = { + builtInVariable: { + pattern: /(\${\w+})/, + }, +}; + +export const DataLinksEditor: FC = React.memo(({ value, onChange, suggestions, maxLinks }) => { + const theme = useContext(ThemeContext); + + const onAdd = () => { + onChange(value ? [...value, { url: '', title: '' }] : [{ url: '', title: '' }]); + }; + + const onLinkChanged = (linkIndex: number, newLink: DataLink) => { + onChange( + value.map((item, listIndex) => { + if (linkIndex === listIndex) { + return newLink; + } + return item; + }) + ); + }; + + const onRemove = (link: DataLink) => { + onChange(value.filter(item => item !== link)); + }; + + return ( + <> + {value && value.length > 0 && ( +
+ {value.map((link, index) => ( + + ))} +
+ )} + + {(!value || (value && value.length < (maxLinks || 1))) && ( + + )} + + ); +}); + +DataLinksEditor.displayName = 'DataLinksEditor'; diff --git a/packages/grafana-ui/src/components/DataLinks/SelectionReference.ts b/packages/grafana-ui/src/components/DataLinks/SelectionReference.ts new file mode 100644 index 00000000000..fa6964f1ee5 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/SelectionReference.ts @@ -0,0 +1,28 @@ +export class SelectionReference { + getBoundingClientRect() { + const selection = window.getSelection(); + const node = selection && selection.anchorNode; + + if (node && node.parentElement) { + const rect = node.parentElement.getBoundingClientRect(); + return rect; + } + + return { + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + }; + } + + get clientWidth() { + return this.getBoundingClientRect().width; + } + + get clientHeight() { + return this.getBoundingClientRect().height; + } +} diff --git a/packages/grafana-ui/src/components/FormField/FormField.tsx b/packages/grafana-ui/src/components/FormField/FormField.tsx index 600fc4038b7..13005dfaca3 100644 --- a/packages/grafana-ui/src/components/FormField/FormField.tsx +++ b/packages/grafana-ui/src/components/FormField/FormField.tsx @@ -1,7 +1,7 @@ import React, { InputHTMLAttributes, FunctionComponent } from 'react'; import { FormLabel } from '../FormLabel/FormLabel'; import { PopperContent } from '../Tooltip/PopperController'; - +import { cx } from 'emotion'; export interface Props extends InputHTMLAttributes { label: string; tooltip?: PopperContent; @@ -25,10 +25,11 @@ export const FormField: FunctionComponent = ({ labelWidth, inputWidth, inputEl, + className, ...inputProps }) => { return ( -
+
{label} diff --git a/packages/grafana-ui/src/components/SingleStatShared/FieldPropertiesEditor.tsx b/packages/grafana-ui/src/components/SingleStatShared/FieldPropertiesEditor.tsx index 4eb00fdc955..60ae3b46921 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/FieldPropertiesEditor.tsx +++ b/packages/grafana-ui/src/components/SingleStatShared/FieldPropertiesEditor.tsx @@ -79,6 +79,7 @@ export const FieldPropertiesEditor: React.FC = ({ value, onChange, showMi {'$' + VAR_CELL_PREFIX + '{N}'} / {'$' + VAR_CALC}
); + return ( <> { internalOnChange = (event: React.FormEvent) => { event.stopPropagation(); - this.props.onChange(event); }; diff --git a/packages/grafana-ui/src/components/Tooltip/_Tooltip.scss b/packages/grafana-ui/src/components/Tooltip/_Tooltip.scss index a33724b3460..a346e8d5f35 100644 --- a/packages/grafana-ui/src/components/Tooltip/_Tooltip.scss +++ b/packages/grafana-ui/src/components/Tooltip/_Tooltip.scss @@ -7,6 +7,12 @@ $popper-margin-from-ref: 5px; .popper__arrow { border-color: $backgroundColor; } + + code { + border: none; + background: darken($backgroundColor, 15%); + color: lighten($textColor, 20%); + } } .popper { @@ -14,7 +20,6 @@ $popper-margin-from-ref: 5px; z-index: $zindex-tooltip; color: $tooltipColor; max-width: 400px; - text-align: center; } .popper__background { diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index abc4d7c2c9f..ceba2a5518e 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -6,6 +6,7 @@ export { Portal } from './Portal/Portal'; export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar'; export * from './Button/Button'; +export { ButtonVariant } from './Button/AbstractButton'; // Select export { Select, AsyncSelect, SelectOptionItem } from './Select/Select'; @@ -65,3 +66,7 @@ export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor'; export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper'; export * from './SingleStatShared/index'; export { CallToActionCard } from './CallToActionCard/CallToActionCard'; +export { ContextMenu, ContextMenuItem, ContextMenuGroup, ContextMenuProps } from './ContextMenu/ContextMenu'; +export { VariableSuggestion, VariableOrigin } from './DataLinks/DataLinkSuggestions'; +export { DataLinksEditor } from './DataLinks/DataLinksEditor'; +export { SeriesIcon } from './Legend/SeriesIcon'; diff --git a/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts index c51de327841..1ff4fc6d51b 100644 --- a/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts @@ -287,7 +287,7 @@ $popover-header-bg: $dark-9; $popover-shadow: 0 0 20px black; $popover-help-bg: $btn-secondary-bg; -$popover-help-color: $text-color; +$popover-help-color: $gray-6; $popover-error-bg: $btn-danger-bg; diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index aedb5b30a87..25f30ea2967 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -147,6 +147,12 @@ export interface RangeMap extends BaseMap { to: string; } +export interface DataLink { + url: string; + title: string; + targetBlank?: boolean; +} + export enum VizOrientation { Auto = 'auto', Vertical = 'vertical', diff --git a/packages/grafana-ui/src/utils/deprecationWarning.ts b/packages/grafana-ui/src/utils/deprecationWarning.ts index 3182f232638..88231eaf0c4 100644 --- a/packages/grafana-ui/src/utils/deprecationWarning.ts +++ b/packages/grafana-ui/src/utils/deprecationWarning.ts @@ -1,6 +1,4 @@ -const deprecationWarning = (file: string, oldName: string, newName: string) => { +export const deprecationWarning = (file: string, oldName: string, newName: string) => { const message = `[Deprecation warning] ${file}: ${oldName} is deprecated. Use ${newName} instead`; console.warn(message); }; - -export default deprecationWarning; diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index c757451dac2..fafbfd882f7 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -17,6 +17,7 @@ export { getFlotPairs } from './flotPairs'; export * from './object'; export * from './fieldCache'; export * from './moment_wrapper'; +export * from './slate'; // Names are too general to export // rangeutils, datemath diff --git a/public/app/features/explore/Value.ts b/packages/grafana-ui/src/utils/slate.ts similarity index 98% rename from public/app/features/explore/Value.ts rename to packages/grafana-ui/src/utils/slate.ts index 78b54f1a43b..e8a8dd71295 100644 --- a/public/app/features/explore/Value.ts +++ b/packages/grafana-ui/src/utils/slate.ts @@ -1,3 +1,4 @@ +// @ts-ignore import { Block, Document, Text, Value } from 'slate'; const SCHEMA = { diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 30163be0edf..fc4a63f5e83 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -8,9 +8,10 @@ import { TagFilter } from './components/TagFilter/TagFilter'; import { SideMenu } from './components/sidemenu/SideMenu'; import { MetricSelect } from './components/Select/MetricSelect'; import AppNotificationList from './components/AppNotifications/AppNotificationList'; -import { ColorPicker, SeriesColorPickerPopoverWithTheme, SecretFormField } from '@grafana/ui'; +import { ColorPicker, SeriesColorPickerPopoverWithTheme, SecretFormField, DataLinksEditor } from '@grafana/ui'; import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor'; import { SearchField } from './components/search/SearchField'; +import { GraphContextMenu } from 'app/plugins/panel/graph/GraphContextMenu'; export function registerAngularDirectives() { react2AngularDirective('passwordStrength', PasswordStrength, ['password']); @@ -72,4 +73,19 @@ export function registerAngularDirectives() { ['onReset', { watchDepth: 'reference', wrapApply: true }], ['onChange', { watchDepth: 'reference', wrapApply: true }], ]); + react2AngularDirective('graphContextMenu', GraphContextMenu, [ + 'x', + 'y', + 'items', + ['onClose', { watchDepth: 'reference', wrapApply: true }], + ['getContextMenuSource', { watchDepth: 'reference', wrapApply: true }], + ]); + + // We keep the drilldown terminology here because of as using data-* directive + // being in conflict with HTML data attributes + react2AngularDirective('drilldownLinksEditor', DataLinksEditor, [ + 'value', + 'suggestions', + ['onChange', { watchDepth: 'reference', wrapApply: true }], + ]); } diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts index 82d0623b1ff..b97b87d9b38 100644 --- a/public/app/core/utils/kbn.ts +++ b/public/app/core/utils/kbn.ts @@ -1,7 +1,6 @@ import { has } from 'lodash'; -import { getValueFormat, getValueFormatterIndex, getValueFormats } from '@grafana/ui'; +import { getValueFormat, getValueFormatterIndex, getValueFormats, deprecationWarning } from '@grafana/ui'; import { stringToJsRegex } from '@grafana/data'; -import deprecationWarning from '@grafana/ui/src/utils/deprecationWarning'; const kbn: any = {}; diff --git a/public/app/core/utils/url.ts b/public/app/core/utils/url.ts index 878904bc7a6..5a2ad5bc7fe 100644 --- a/public/app/core/utils/url.ts +++ b/public/app/core/utils/url.ts @@ -71,3 +71,19 @@ export function toUrlParams(a: any) { return buildParams('', a).join('&'); } + +export function appendQueryToUrl(url, stringToAppend) { + if (stringToAppend !== undefined && stringToAppend !== null && stringToAppend !== '') { + const pos = url.indexOf('?'); + if (pos !== -1) { + if (url.length - pos > 1) { + url += '&'; + } + } else { + url += '?'; + } + url += stringToAppend; + } + + return url; +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts index 9d3c36ee2bc..0874e8e55b5 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts +++ b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts @@ -1,6 +1,7 @@ import angular from 'angular'; import config from 'app/core/config'; import { dateTime } from '@grafana/ui/src/utils/moment_wrapper'; +import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url'; /** @ngInject */ export function ShareModalCtrl($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) { @@ -72,13 +73,13 @@ export function ShareModalCtrl($scope, $rootScope, $location, $timeout, timeSrv, delete params.fullscreen; } - $scope.shareUrl = linkSrv.addParamsToUrl(baseUrl, params); + $scope.shareUrl = appendQueryToUrl(baseUrl, toUrlParams(params)); let soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/'); soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/'); delete params.fullscreen; delete params.edit; - soloUrl = linkSrv.addParamsToUrl(soloUrl, params); + soloUrl = appendQueryToUrl(soloUrl, toUrlParams(params)); $scope.iframeHtml = ''; diff --git a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap index d6be7658841..26299511b61 100644 --- a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap +++ b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap @@ -78,7 +78,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 18, + "schemaVersion": 19, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -191,7 +191,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 18, + "schemaVersion": 19, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -315,7 +315,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti ], "refresh": undefined, "revision": undefined, - "schemaVersion": 18, + "schemaVersion": 19, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -426,7 +426,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti ], "refresh": undefined, "revision": undefined, - "schemaVersion": 18, + "schemaVersion": 19, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -521,7 +521,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti ], "refresh": undefined, "revision": undefined, - "schemaVersion": 18, + "schemaVersion": 19, "snapshot": undefined, "style": "dark", "tags": Array [], diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index 6e639de569c..650762e0a7a 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -9,7 +9,7 @@ import templateSrv from 'app/features/templating/template_srv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { ClickOutsideWrapper } from '@grafana/ui'; +import { ClickOutsideWrapper, DataLink } from '@grafana/ui'; export interface Props { panel: PanelModel; @@ -18,7 +18,7 @@ export interface Props { title?: string; description?: string; scopedVars?: ScopedVars; - links?: []; + links?: DataLink[]; error?: string; isFullscreen: boolean; } diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx index 9d057d5338e..d3bd38b93d1 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx @@ -1,6 +1,6 @@ import React, { Component } from 'react'; import Remarkable from 'remarkable'; -import { Tooltip, ScopedVars } from '@grafana/ui'; +import { Tooltip, ScopedVars, DataLink } from '@grafana/ui'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import templateSrv from 'app/features/templating/template_srv'; @@ -18,7 +18,7 @@ interface Props { title?: string; description?: string; scopedVars?: ScopedVars; - links?: []; + links?: DataLink[]; error?: string; } @@ -48,15 +48,15 @@ export class PanelHeaderCorner extends Component { const remarkableInterpolatedMarkdown = new Remarkable().render(interpolatedMarkdown); return ( -
-
+
+

{panel.links && panel.links.length > 0 && ( -