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