Graph: Add data links feature (click on graph) (#17267)
* WIP: initial panel links editor * WIP: Added dashboard migration to new panel drilldown link schema * Make link_srv interpolate new variables * Fix failing tests * Drilldown: Add context menu to graph viz (#17284) * Add simple context menu for adding graph annotations and showing drilldown links * Close graph context menu when user start scrolling * Move context menu component to grafana/ui * Make graph context menu appear on click, use cmd/ctrl click for quick annotations * Move graph context menu controller to separate file * Drilldown: datapoint variables interpolation (#17328) * Add simple context menu for adding graph annotations and showing drilldown links * Close graph context menu when user start scrolling * Move context menu component to grafana/ui * Make graph context menu appear on click, use cmd/ctrl click for quick annotations * Add util for absolute time range transformation * Add series name and datapoint timestamp interpolation * Rename drilldown link variables tot snake case, use const values instead of strings in tests * Bring LinkSrv.getPanelLinkAnchorInfo for compatibility reasons and add deprecation warning * Rename seriesLabel to seriesName * Drilldown: use separate editors for panel and series links (#17355) * Use correct target ini context menu links * Rename PanelLinksEditor to DrilldownLinksEditor and mote it to grafana/ui * Expose DrilldownLinksEditor as an angular directive * Enable visualization specifix drilldown links * Props interfaces rename * Drilldown: Add variables suggestion and syntax highlighting for drilldown link editor (#17391) * Add variables suggestion in drilldown link editor * Enable prism * Fix backspace not working * Move slate value helpers to grafana/ui * Add syntax higlighting for links input * Rename drilldown link components to data links * Add template variabe suggestions * Bugfix * Fix regexp not working in Firefox * Display correct links in panel header corner * bugfix * bugfix * Bugfix * Context menu UI tweaks * Use data link terminology instead of drilldown * DataLinks: changed autocomplete syntax * Use singular form for data link * Use the same syntax higlighting for built-in and template variables in data links editor * UI improvements to context menu * UI review tweaks * Tweak layout of data link editor * Fix vertical spacing * Remove data link header in context menu * Remove pointer cursor from series label in context menu * Fix variable selection on click * DataLinks: migrations for old links * Update docs about data links * Use value time instead of time range when interpolating datapoint timestamp * Remove not used util * Update docs * Moved icon a bit more down * Interpolate value ts only when using __value_time variable * Bring href property back to LinkModel * Add any type annotations * Fix TS error on slate's Value type * minor changes
This commit is contained in:
+1
-1
@@ -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) => {
|
||||
|
||||
@@ -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<HTMLElement>) => 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<ContextMenuProps> = 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 (
|
||||
<Portal>
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: x - 5,
|
||||
top: y + 5,
|
||||
}}
|
||||
className={styles.wrapper}
|
||||
>
|
||||
{renderHeader && <div className={styles.header}>{renderHeader()}</div>}
|
||||
<List
|
||||
items={items || []}
|
||||
renderItem={(item, index) => {
|
||||
return (
|
||||
<>
|
||||
<ContextMenuGroup group={item} onItemClick={onClose} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
||||
interface ContextMenuItemProps {
|
||||
label: string;
|
||||
icon?: string;
|
||||
url?: string;
|
||||
target?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ContextMenuItem: React.FC<ContextMenuItemProps> = React.memo(
|
||||
({ url, icon, label, target, onClick, className }) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const styles = getContextMenuStyles(theme);
|
||||
return (
|
||||
<div className={styles.item}>
|
||||
<a
|
||||
href={url}
|
||||
target={target || '_self'}
|
||||
className={cx(className, styles.link)}
|
||||
onClick={e => {
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{icon && <i className={cx(`${icon}`, styles.icon)} />} {label}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface ContextMenuGroupProps {
|
||||
group: ContextMenuGroup;
|
||||
onItemClick?: () => void;
|
||||
}
|
||||
|
||||
const ContextMenuGroup: React.FC<ContextMenuGroupProps> = ({ group, onItemClick }) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const styles = getContextMenuStyles(theme);
|
||||
|
||||
if (group.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{group.label && <div className={styles.groupLabel}>{group.label}</div>}
|
||||
<List
|
||||
items={group.items || []}
|
||||
renderItem={item => {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
url={item.url}
|
||||
label={item.label}
|
||||
target={item.target}
|
||||
icon={item.icon}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
||||
if (item.onClick) {
|
||||
item.onClick(e);
|
||||
}
|
||||
|
||||
if (onItemClick) {
|
||||
onItemClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
ContextMenu.displayName = 'ContextMenu';
|
||||
@@ -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<DataLinkEditorProps> = 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<HTMLInputElement>) => {
|
||||
setTitle(event.target.value);
|
||||
};
|
||||
|
||||
const onTitleBlur = () => {
|
||||
onChange(index, { ...value, title: title });
|
||||
};
|
||||
|
||||
const onRemoveClick = () => {
|
||||
onRemove(value);
|
||||
};
|
||||
|
||||
const onOpenInNewTabChanged = () => {
|
||||
onChange(index, { ...value, targetBlank: !value.targetBlank });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'gf-form gf-form--inline',
|
||||
css`
|
||||
> * {
|
||||
margin-right: ${theme.spacing.xs};
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={onTitleChange}
|
||||
onBlur={onTitleBlur}
|
||||
inputWidth={15}
|
||||
labelWidth={5}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="URL"
|
||||
labelWidth={4}
|
||||
inputEl={<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />}
|
||||
className={css`
|
||||
width: 100%;
|
||||
`}
|
||||
/>
|
||||
|
||||
<Switch label="Open in new tab" checked={value.targetBlank || false} onChange={onOpenInNewTabChanged} />
|
||||
|
||||
<div className="gf-form">
|
||||
<button className="gf-form-label gf-form-label--btn" onClick={onRemoveClick}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DataLinkEditor.displayName = 'DataLinkEditor';
|
||||
@@ -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<DataLinkInputProps> = ({ 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<any>(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 (
|
||||
<div
|
||||
className={cx(
|
||||
'gf-form-input',
|
||||
css`
|
||||
position: relative;
|
||||
height: auto;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div className="slate-query-field">
|
||||
{showingSuggestions && (
|
||||
<Portal>
|
||||
<ReactPopper
|
||||
referenceElement={selectionRef}
|
||||
placement="auto-end"
|
||||
modifiers={{
|
||||
preventOverflow: { enabled: true, boundariesElement: 'window' },
|
||||
arrow: { enabled: false },
|
||||
offset: { offset: 200 }, // width of the suggestions menu
|
||||
}}
|
||||
>
|
||||
{({ ref, style, placement }) => {
|
||||
return (
|
||||
<div ref={ref} style={style} data-placement={placement}>
|
||||
<DataLinkSuggestions
|
||||
suggestions={currentSuggestions}
|
||||
onSuggestionSelect={onVariableSelect}
|
||||
onClose={() => setShowingSuggestions(false)}
|
||||
activeIndex={suggestionsIndex}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</ReactPopper>
|
||||
</Portal>
|
||||
)}
|
||||
<Editor
|
||||
placeholder="http://your-grafana.com/d/000000010/annotations"
|
||||
value={linkUrl}
|
||||
onChange={onUrlChange}
|
||||
onBlur={onUrlBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
plugins={plugins}
|
||||
className={getStyles().editor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DataLinkInput.displayName = 'DataLinkInput';
|
||||
@@ -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<DataLinkSuggestionsProps> = ({ 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 (
|
||||
<div ref={ref} className={styles.wrapper}>
|
||||
{templateSuggestions.length > 0 && (
|
||||
<DataLinkSuggestionsList
|
||||
{...otherProps}
|
||||
suggestions={templateSuggestions}
|
||||
label="Template variables"
|
||||
activeIndex={otherProps.activeIndex}
|
||||
activeIndexOffset={0}
|
||||
/>
|
||||
)}
|
||||
{builtInSuggestions.length > 0 && (
|
||||
<DataLinkSuggestionsList
|
||||
{...otherProps}
|
||||
suggestions={builtInSuggestions}
|
||||
label="Built-in variables"
|
||||
activeIndexOffset={templateSuggestions.length}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DataLinkSuggestions.displayName = 'DataLinkSuggestions';
|
||||
|
||||
interface DataLinkSuggestionsListProps extends DataLinkSuggestionsProps {
|
||||
label: string;
|
||||
activeIndexOffset: number;
|
||||
}
|
||||
|
||||
const DataLinkSuggestionsList: React.FC<DataLinkSuggestionsListProps> = React.memo(
|
||||
({ activeIndex, activeIndexOffset, label, onClose, onSuggestionSelect, suggestions }) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.label}>{label}</div>
|
||||
<List
|
||||
items={suggestions}
|
||||
renderItem={(item, index) => {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.item, index + activeIndexOffset === activeIndex && styles.activeItem)}
|
||||
onClick={() => {
|
||||
onSuggestionSelect(item);
|
||||
}}
|
||||
>
|
||||
<div className={styles.itemValue}>{item.value}</div>
|
||||
{item.documentation && <div className={styles.itemDocs}>{item.documentation}</div>}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DataLinkSuggestionsList.displayName = 'DataLinkSuggestionsList';
|
||||
@@ -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<DataLinksEditorProps> = 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 && (
|
||||
<div
|
||||
className={css`
|
||||
margin-bottom: ${theme.spacing.sm};
|
||||
`}
|
||||
>
|
||||
{value.map((link, index) => (
|
||||
<DataLinkEditor
|
||||
key={index.toString()}
|
||||
index={index}
|
||||
value={link}
|
||||
onChange={onLinkChanged}
|
||||
onRemove={onRemove}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!value || (value && value.length < (maxLinks || 1))) && (
|
||||
<Button variant="inverse" icon="fa fa-plus" onClick={() => onAdd()}>
|
||||
Create link
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
DataLinksEditor.displayName = 'DataLinksEditor';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLInputElement> {
|
||||
label: string;
|
||||
tooltip?: PopperContent<any>;
|
||||
@@ -25,10 +25,11 @@ export const FormField: FunctionComponent<Props> = ({
|
||||
labelWidth,
|
||||
inputWidth,
|
||||
inputEl,
|
||||
className,
|
||||
...inputProps
|
||||
}) => {
|
||||
return (
|
||||
<div className="form-field">
|
||||
<div className={cx('form-field', className)}>
|
||||
<FormLabel width={labelWidth} tooltip={tooltip}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
|
||||
@@ -79,6 +79,7 @@ export const FieldPropertiesEditor: React.FC<Props> = ({ value, onChange, showMi
|
||||
{'$' + VAR_CELL_PREFIX + '{N}'} / {'$' + VAR_CALC}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
|
||||
@@ -22,7 +22,6 @@ export class Switch extends PureComponent<Props, State> {
|
||||
|
||||
internalOnChange = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
this.props.onChange(event);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// @ts-ignore
|
||||
import { Block, Document, Text, Value } from 'slate';
|
||||
|
||||
const SCHEMA = {
|
||||
blocks: {
|
||||
paragraph: 'paragraph',
|
||||
codeblock: 'code_block',
|
||||
codeline: 'code_line',
|
||||
},
|
||||
inlines: {},
|
||||
marks: {},
|
||||
};
|
||||
|
||||
export const makeFragment = (text: string, syntax?: string) => {
|
||||
const lines = text.split('\n').map(line =>
|
||||
Block.create({
|
||||
type: 'code_line',
|
||||
nodes: [Text.create(line)],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const block = Block.create({
|
||||
data: {
|
||||
syntax,
|
||||
},
|
||||
type: 'code_block',
|
||||
nodes: lines,
|
||||
} as any);
|
||||
|
||||
return Document.create({
|
||||
nodes: [block],
|
||||
});
|
||||
};
|
||||
|
||||
export const makeValue = (text: string, syntax?: string) => {
|
||||
const fragment = makeFragment(text, syntax);
|
||||
|
||||
return Value.create({
|
||||
document: fragment,
|
||||
SCHEMA,
|
||||
} as any);
|
||||
};
|
||||
Reference in New Issue
Block a user