241 lines
7.3 KiB
TypeScript
241 lines
7.3 KiB
TypeScript
import { css } from '@emotion/css';
|
|
import { autoUpdate, flip, shift, useFloating } from '@floating-ui/react';
|
|
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
|
import * as React from 'react';
|
|
|
|
import { GrafanaTheme2, VariableSuggestion } from '@grafana/data';
|
|
import { FieldValidationMessage, Portal, ScrollContainer, TextArea, useTheme2 } from '@grafana/ui';
|
|
import { DataLinkSuggestions } from '@grafana/ui/src/components/DataLinks/DataLinkSuggestions';
|
|
import { Input } from '@grafana/ui/src/components/Input/Input';
|
|
|
|
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
|
|
const ERROR_TOOLTIP_OFFSET = 8;
|
|
|
|
export enum HTMLElementType {
|
|
InputElement = 'input',
|
|
TextAreaElement = 'textarea',
|
|
}
|
|
|
|
interface SuggestionsInputProps {
|
|
value?: string | number;
|
|
onChange: (url: string, callback?: () => void) => void;
|
|
suggestions: VariableSuggestion[];
|
|
placeholder?: string;
|
|
invalid?: boolean;
|
|
error?: string;
|
|
width?: number;
|
|
type?: HTMLElementType;
|
|
style?: React.CSSProperties;
|
|
autoFocus?: boolean;
|
|
}
|
|
|
|
const getStyles = (theme: GrafanaTheme2, inputHeight: number) => {
|
|
return {
|
|
suggestionsWrapper: css({
|
|
boxShadow: theme.shadows.z2,
|
|
}),
|
|
errorTooltip: css({
|
|
position: 'absolute',
|
|
top: inputHeight + ERROR_TOOLTIP_OFFSET + 'px',
|
|
zIndex: theme.zIndex.tooltip,
|
|
}),
|
|
inputWrapper: css({
|
|
position: 'relative',
|
|
}),
|
|
// Wrapper with child selector needed.
|
|
// When classnames are applied to the same element as the wrapper, it causes the suggestions to stop working
|
|
};
|
|
};
|
|
|
|
export const SuggestionsInput = ({
|
|
value = '',
|
|
onChange,
|
|
suggestions,
|
|
placeholder,
|
|
error,
|
|
invalid,
|
|
type = HTMLElementType.InputElement,
|
|
style,
|
|
autoFocus = false,
|
|
}: SuggestionsInputProps) => {
|
|
const [showingSuggestions, setShowingSuggestions] = useState(false);
|
|
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
|
|
const [variableValue, setVariableValue] = useState<string>(value.toString());
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const [scrollTop, setScrollTop] = useState(0);
|
|
const [inputHeight, setInputHeight] = useState<number>(0);
|
|
const [startPos, setStartPos] = useState<number>(0);
|
|
|
|
const theme = useTheme2();
|
|
const styles = getStyles(theme, inputHeight);
|
|
|
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>();
|
|
|
|
useEffect(() => {
|
|
scrollRef.current?.scrollTo(0, scrollTop);
|
|
}, [scrollTop]);
|
|
|
|
// the order of middleware is important!
|
|
const middleware = [
|
|
flip({
|
|
fallbackAxisSideDirection: 'start',
|
|
// see https://floating-ui.com/docs/flip#combining-with-shift
|
|
crossAxis: false,
|
|
boundary: document.body,
|
|
}),
|
|
shift(),
|
|
];
|
|
|
|
const { refs, floatingStyles } = useFloating({
|
|
open: showingSuggestions,
|
|
placement: 'bottom-start',
|
|
onOpenChange: setShowingSuggestions,
|
|
middleware,
|
|
whileElementsMounted: autoUpdate,
|
|
strategy: 'fixed',
|
|
});
|
|
|
|
const handleRef = useCallback(
|
|
(ref: HTMLInputElement | HTMLTextAreaElement) => {
|
|
refs.setReference(ref);
|
|
|
|
inputRef.current = ref;
|
|
},
|
|
[refs]
|
|
);
|
|
|
|
// Used to get the height of the suggestion elements in order to scroll to them.
|
|
const activeRef = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
setScrollTop(getElementPosition(activeRef.current, suggestionsIndex));
|
|
}, [suggestionsIndex]);
|
|
|
|
const onVariableSelect = React.useCallback(
|
|
(item: VariableSuggestion, input = inputRef.current!) => {
|
|
const curPos = input.selectionStart!;
|
|
const x = input.value;
|
|
|
|
if (x[startPos - 1] === '$') {
|
|
input.value = x.slice(0, startPos) + item.value + x.slice(curPos);
|
|
} else {
|
|
input.value = x.slice(0, startPos) + '$' + `{${item.value}}` + x.slice(curPos);
|
|
}
|
|
|
|
setVariableValue(input.value);
|
|
setShowingSuggestions(false);
|
|
|
|
setSuggestionsIndex(0);
|
|
onChange(input.value);
|
|
},
|
|
[onChange, startPos]
|
|
);
|
|
|
|
const onKeyDown = React.useCallback(
|
|
(event: React.KeyboardEvent) => {
|
|
if (!showingSuggestions) {
|
|
if (event.key === '$' || (event.key === ' ' && event.ctrlKey)) {
|
|
setStartPos(inputRef.current!.selectionStart || 0);
|
|
setShowingSuggestions(true);
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch (event.key) {
|
|
case 'Backspace':
|
|
case 'Escape':
|
|
case 'ArrowLeft':
|
|
case 'ArrowRight':
|
|
setShowingSuggestions(false);
|
|
return setSuggestionsIndex(0);
|
|
|
|
case 'Enter':
|
|
event.preventDefault();
|
|
return onVariableSelect(suggestions[suggestionsIndex]);
|
|
|
|
case 'ArrowDown':
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
|
return setSuggestionsIndex((index) => modulo(index + direction, suggestions.length));
|
|
default:
|
|
return;
|
|
}
|
|
},
|
|
[showingSuggestions, suggestions, suggestionsIndex, onVariableSelect]
|
|
);
|
|
|
|
const onValueChanged = React.useCallback((event: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
setVariableValue(event.currentTarget.value);
|
|
}, []);
|
|
|
|
const onBlur = React.useCallback(
|
|
(event: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
onChange(event.currentTarget.value);
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
useEffect(() => {
|
|
setInputHeight(inputRef.current!.clientHeight);
|
|
}, []);
|
|
|
|
const inputProps = {
|
|
placeholder,
|
|
invalid,
|
|
value: variableValue,
|
|
onChange: onValueChanged,
|
|
onBlur: onBlur,
|
|
onKeyDown: onKeyDown,
|
|
};
|
|
|
|
return (
|
|
<div className={styles.inputWrapper} style={style ?? {}}>
|
|
{showingSuggestions && (
|
|
<Portal>
|
|
<div ref={refs.setFloating} style={floatingStyles} className={styles.suggestionsWrapper}>
|
|
<ScrollContainer
|
|
maxHeight="300px"
|
|
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop ?? 0)}
|
|
ref={scrollRef}
|
|
>
|
|
{/* This suggestion component has a specialized name,
|
|
but is rather generalistic in implementation,
|
|
so we're using it in transformations also.
|
|
We should probably rename this to something more general. */}
|
|
<DataLinkSuggestions
|
|
activeRef={activeRef}
|
|
suggestions={suggestions}
|
|
onSuggestionSelect={onVariableSelect}
|
|
onClose={() => setShowingSuggestions(false)}
|
|
activeIndex={suggestionsIndex}
|
|
/>
|
|
</ScrollContainer>
|
|
</div>
|
|
</Portal>
|
|
)}
|
|
{invalid && error && (
|
|
<div className={styles.errorTooltip}>
|
|
<FieldValidationMessage>{error}</FieldValidationMessage>
|
|
</div>
|
|
)}
|
|
{type === HTMLElementType.InputElement ? (
|
|
<Input {...inputProps} ref={handleRef as unknown as React.RefObject<HTMLInputElement>} autoFocus={autoFocus} />
|
|
) : (
|
|
<TextArea
|
|
{...inputProps}
|
|
ref={handleRef as unknown as React.RefObject<HTMLTextAreaElement>}
|
|
autoFocus={autoFocus}
|
|
rows={5}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
SuggestionsInput.displayName = 'SuggestionsInput';
|
|
|
|
function getElementPosition(suggestionElement: HTMLElement | null, activeIndex: number) {
|
|
return (suggestionElement?.clientHeight ?? 0) * activeIndex;
|
|
}
|