1import React, { memo, RefObject, useEffect, useMemo, useRef, useState } from 'react'; 2import usePrevious from 'react-use/lib/usePrevious'; 3import { DataLinkSuggestions } from './DataLinkSuggestions'; 4import { makeValue } from '../../index'; 5import { SelectionReference } from './SelectionReference'; 6import { Portal } from '../index'; 7 8// @ts-ignore 9import Prism, { Grammar, LanguageMap } from 'prismjs'; 10import { Editor } from '@grafana/slate-react'; 11import { Value } from 'slate'; 12import Plain from 'slate-plain-serializer'; 13import { Popper as ReactPopper } from 'react-popper'; 14import { css, cx } from '@emotion/css'; 15 16import { SlatePrism } from '../../slate-plugins'; 17import { SCHEMA } from '../../utils/slate'; 18import { useStyles2 } from '../../themes'; 19import { DataLinkBuiltInVars, GrafanaTheme2, VariableOrigin, VariableSuggestion } from '@grafana/data'; 20import { getInputStyles } from '../Input/Input'; 21import CustomScrollbar from '../CustomScrollbar/CustomScrollbar'; 22 23const modulo = (a: number, n: number) => a - n * Math.floor(a / n); 24 25interface DataLinkInputProps { 26 value: string; 27 onChange: (url: string, callback?: () => void) => void; 28 suggestions: VariableSuggestion[]; 29 placeholder?: string; 30} 31 32const datalinksSyntax: Grammar = { 33 builtInVariable: { 34 pattern: /(\${\S+?})/, 35 }, 36}; 37 38const plugins = [ 39 SlatePrism( 40 { 41 onlyIn: (node: any) => node.type === 'code_block', 42 getSyntax: () => 'links', 43 }, 44 { ...(Prism.languages as LanguageMap), links: datalinksSyntax } 45 ), 46]; 47 48const getStyles = (theme: GrafanaTheme2) => ({ 49 input: getInputStyles({ theme, invalid: false }).input, 50 editor: css` 51 .token.builtInVariable { 52 color: ${theme.colors.success.text}; 53 } 54 .token.variable { 55 color: ${theme.colors.primary.text}; 56 } 57 `, 58 suggestionsWrapper: css` 59 box-shadow: ${theme.shadows.z2}; 60 `, 61 // Wrapper with child selector needed. 62 // When classnames are applied to the same element as the wrapper, it causes the suggestions to stop working 63 wrapperOverrides: css` 64 width: 100%; 65 > .slate-query-field__wrapper { 66 padding: 0; 67 background-color: transparent; 68 border: none; 69 } 70 `, 71}); 72 73// This memoised also because rerendering the slate editor grabs focus which created problem in some cases this 74// was used and changes to different state were propagated here. 75export const DataLinkInput: React.FC<DataLinkInputProps> = memo( 76 ({ value, onChange, suggestions, placeholder = 'http://your-grafana.com/d/000000010/annotations' }) => { 77 const editorRef = useRef<Editor>() as RefObject<Editor>; 78 const styles = useStyles2(getStyles); 79 const [showingSuggestions, setShowingSuggestions] = useState(false); 80 const [suggestionsIndex, setSuggestionsIndex] = useState(0); 81 const [linkUrl, setLinkUrl] = useState<Value>(makeValue(value)); 82 const prevLinkUrl = usePrevious<Value>(linkUrl); 83 84 // Workaround for https://github.com/ianstormtaylor/slate/issues/2927 85 const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }); 86 stateRef.current = { showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }; 87 88 // Used to get the height of the suggestion elements in order to scroll to them. 89 const activeRef = useRef<HTMLDivElement>(null); 90 const activeIndexPosition = useMemo(() => getElementPosition(activeRef.current, suggestionsIndex), [ 91 suggestionsIndex, 92 ]); 93 94 // SelectionReference is used to position the variables suggestion relatively to current DOM selection 95 const selectionRef = useMemo(() => new SelectionReference(), []); 96 97 const onKeyDown = React.useCallback((event: KeyboardEvent, next: () => any) => { 98 if (!stateRef.current.showingSuggestions) { 99 if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) { 100 return setShowingSuggestions(true); 101 } 102 return next(); 103 } 104 105 switch (event.key) { 106 case 'Backspace': 107 case 'Escape': 108 setShowingSuggestions(false); 109 return setSuggestionsIndex(0); 110 111 case 'Enter': 112 event.preventDefault(); 113 return onVariableSelect(stateRef.current.suggestions[stateRef.current.suggestionsIndex]); 114 115 case 'ArrowDown': 116 case 'ArrowUp': 117 event.preventDefault(); 118 const direction = event.key === 'ArrowDown' ? 1 : -1; 119 return setSuggestionsIndex((index) => modulo(index + direction, stateRef.current.suggestions.length)); 120 default: 121 return next(); 122 } 123 }, []); 124 125 useEffect(() => { 126 // Update the state of the link in the parent. This is basically done on blur but we need to do it after 127 // our state have been updated. The duplicity of state is done for perf reasons and also because local 128 // state also contains things like selection and formating. 129 if (prevLinkUrl && prevLinkUrl.selection.isFocused && !linkUrl.selection.isFocused) { 130 stateRef.current.onChange(Plain.serialize(linkUrl)); 131 } 132 }, [linkUrl, prevLinkUrl]); 133 134 const onUrlChange = React.useCallback(({ value }: { value: Value }) => { 135 setLinkUrl(value); 136 }, []); 137 138 const onVariableSelect = (item: VariableSuggestion, editor = editorRef.current!) => { 139 const includeDollarSign = Plain.serialize(editor.value).slice(-1) !== '$'; 140 if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) { 141 editor.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`); 142 } else { 143 editor.insertText(`\${${item.value}:queryparam}`); 144 } 145 146 setLinkUrl(editor.value); 147 setShowingSuggestions(false); 148 149 setSuggestionsIndex(0); 150 stateRef.current.onChange(Plain.serialize(editor.value)); 151 }; 152 153 return ( 154 <div className={styles.wrapperOverrides}> 155 <div className="slate-query-field__wrapper"> 156 <div className="slate-query-field"> 157 {showingSuggestions && ( 158 <Portal> 159 <ReactPopper 160 referenceElement={selectionRef} 161 placement="bottom-end" 162 modifiers={[ 163 { 164 name: 'preventOverflow', 165 enabled: true, 166 options: { 167 rootBoundary: 'viewport', 168 }, 169 }, 170 { 171 name: 'arrow', 172 enabled: false, 173 }, 174 { 175 name: 'offset', 176 options: { 177 offset: [250, 0], 178 }, 179 }, 180 ]} 181 > 182 {({ ref, style, placement }) => { 183 return ( 184 <div ref={ref} style={style} data-placement={placement} className={styles.suggestionsWrapper}> 185 <CustomScrollbar scrollTop={activeIndexPosition} autoHeightMax="300px"> 186 <DataLinkSuggestions 187 activeRef={activeRef} 188 suggestions={stateRef.current.suggestions} 189 onSuggestionSelect={onVariableSelect} 190 onClose={() => setShowingSuggestions(false)} 191 activeIndex={suggestionsIndex} 192 /> 193 </CustomScrollbar> 194 </div> 195 ); 196 }} 197 </ReactPopper> 198 </Portal> 199 )} 200 <Editor 201 schema={SCHEMA} 202 ref={editorRef} 203 placeholder={placeholder} 204 value={stateRef.current.linkUrl} 205 onChange={onUrlChange} 206 onKeyDown={(event, _editor, next) => onKeyDown(event as KeyboardEvent, next)} 207 plugins={plugins} 208 className={cx( 209 styles.editor, 210 styles.input, 211 css` 212 padding: 3px 8px; 213 ` 214 )} 215 /> 216 </div> 217 </div> 218 </div> 219 ); 220 } 221); 222 223DataLinkInput.displayName = 'DataLinkInput'; 224 225function getElementPosition(suggestionElement: HTMLElement | null, activeIndex: number) { 226 return (suggestionElement?.clientHeight ?? 0) * activeIndex; 227} 228