Migrate query editor to react (#1520)

* Initial react query editor

* CI: run checks on all branches

* Update react packages

* Initial metric picker

* Load metrics

* Tweak styles

* Add variables to metric options

* Tweak styles

* Filtering and keyboard navigation

* Open menu with keyboard

* Update function editor

* Move functions in editor

* Add function component

* Edit func params

* Push alias functions to the end

* Tweak labels size

* Fix menu position

* Metric options editor

* Fix css styles building

* More work on query options

* Fix tests

* Refactor: extract metrics query editor and functions editor

* Refactor: move things around

* Text metrics editor

* Problems query editor

* Problems mode options

* Item id query editor

* IT services query editor

* Triggers query editor

* Refactor: remove unused

* remove derprecated theme usage

* Load proxy options

* Fetch metric options on variable change

* Remove angular query editor

* Migrate annotations editor to react

* Fix tests
This commit is contained in:
Alexander Zobnin
2022-11-09 17:50:13 +03:00
committed by GitHub
parent f765d47fed
commit 504c9af226
44 changed files with 7822 additions and 5868 deletions

View File

@@ -0,0 +1,141 @@
import { css, cx } from '@emotion/css';
import React, { FormEvent, useCallback, useEffect, useState, useRef } from 'react';
import { ClickOutsideWrapper, Icon, Input, Spinner, useStyles2 } from '@grafana/ui';
import { MetricPickerMenu } from './MetricPickerMenu';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { isRegex } from '../../datasource-zabbix/utils';
export interface Props {
value: string;
isLoading?: boolean;
options: SelectableValue<string>[];
width?: number;
onChange: (value: string) => void;
}
export const MetricPicker = ({ value, options, isLoading, width, onChange }: Props): JSX.Element => {
const [isOpen, setOpen] = useState(false);
const [query, setQuery] = useState(value);
const [filteredOptions, setFilteredOptions] = useState(options);
const [selectedOptionIdx, setSelectedOptionIdx] = useState(-1);
const [offset, setOffset] = useState({ vertical: 0, horizontal: 0 });
const ref = useRef<HTMLDivElement>(null);
const customStyles = useStyles2(getStyles);
const inputClass = cx({
[customStyles.inputRegexp]: isRegex(query),
[customStyles.inputVariable]: query.startsWith('$'),
});
useEffect(() => {
setFilteredOptions(options);
}, [options]);
const onOpen = () => {
setOpen(true);
setFilteredOptions(options);
};
const onClose = useCallback(() => {
setOpen(false);
}, []);
// Only call onClose if menu is open. Prevent unnecessary calls for multiple pickers on the page.
const onClickOutside = () => isOpen && onClose();
const onInputChange = (v: FormEvent<HTMLInputElement>) => {
if (!isOpen) {
setOpen(true);
}
const newQuery = v?.currentTarget?.value;
if (newQuery) {
setQuery(newQuery);
if (value != newQuery) {
const filtered = options.filter(
(option) =>
option.value?.toLowerCase().includes(newQuery.toLowerCase()) ||
option.label?.toLowerCase().includes(newQuery.toLowerCase())
);
setFilteredOptions(filtered);
} else {
setFilteredOptions(options);
}
} else {
setQuery('');
setFilteredOptions(options);
}
};
const onMenuOptionSelect = (option: SelectableValue<string>) => {
const newValue = option?.value || '';
setQuery(newValue);
onChange(newValue);
onClose();
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
if (!isOpen) {
setOpen(true);
}
e.preventDefault();
e.stopPropagation();
const selected = selectedOptionIdx < filteredOptions.length - 1 ? selectedOptionIdx + 1 : 0;
setSelectedOptionIdx(selected);
} else if (e.key === 'ArrowUp') {
if (!isOpen) {
setOpen(true);
}
e.preventDefault();
e.stopPropagation();
const selected = selectedOptionIdx > 0 ? selectedOptionIdx - 1 : filteredOptions.length - 1;
setSelectedOptionIdx(selected);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
onMenuOptionSelect(filteredOptions[selectedOptionIdx]);
}
};
return (
<div data-testid="role-picker" style={{ position: 'relative' }} ref={ref}>
<ClickOutsideWrapper onClick={onClickOutside}>
<Input
className={inputClass}
value={query}
type="text"
onChange={onInputChange}
onBlur={() => onChange(query)}
onMouseDown={onOpen}
suffix={isLoading && <Spinner />}
width={width}
onKeyDown={onKeyDown}
/>
{isOpen && (
<MetricPickerMenu
options={filteredOptions}
onSelect={onMenuOptionSelect}
offset={offset}
minWidth={width}
selected={selectedOptionIdx}
/>
)}
</ClickOutsideWrapper>
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
inputRegexp: css`
input {
color: ${theme.colors.warning.main};
}
`,
inputVariable: css`
input {
color: ${theme.colors.primary.text};
}
`,
};
};

View File

@@ -0,0 +1,138 @@
import { css, cx } from '@emotion/css';
import React, { FormEvent } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { CustomScrollbar, getSelectStyles, Icon, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
import { MENU_MAX_HEIGHT } from './constants';
interface Props {
options: SelectableValue<string>[];
onSelect: (option: SelectableValue<string>) => void;
offset: { vertical: number; horizontal: number };
minWidth?: number;
selected?: number;
}
export const MetricPickerMenu = ({ options, offset, minWidth, selected, onSelect }: Props): JSX.Element => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const customStyles = useStyles2(getStyles(minWidth));
return (
<div
className={cx(
styles.menu,
customStyles.menuWrapper,
{ [customStyles.menuLeft]: offset.horizontal > 0 },
css`
bottom: ${offset.vertical > 0 ? `${offset.vertical}px` : 'unset'};
top: ${offset.vertical < 0 ? `${Math.abs(offset.vertical)}px` : 'unset'};
`
)}
>
<div className={customStyles.menu} aria-label="Metric picker menu">
<CustomScrollbar autoHide={false} autoHeightMax={`${MENU_MAX_HEIGHT}px`} hideHorizontalTrack>
<div>
<div className={styles.optionBody}>
{options?.map((option, i) => (
<MenuOption data={option} key={i} onClick={onSelect} isFocused={selected === i} hideDescription />
))}
</div>
</div>
</CustomScrollbar>
</div>
</div>
);
};
interface MenuOptionProps<T> {
data: SelectableValue<string>;
onClick: (value: SelectableValue<string>) => void;
isFocused?: boolean;
disabled?: boolean;
hideDescription?: boolean;
}
const MenuOption = React.forwardRef<HTMLDivElement, React.PropsWithChildren<MenuOptionProps<any>>>(
({ data, isFocused, disabled, onClick, hideDescription }, ref) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const customStyles = useStyles2(getStyles());
const wrapperClassName = cx(
styles.option,
customStyles.menuOptionWrapper,
isFocused && styles.optionFocused,
disabled && customStyles.menuOptionDisabled
);
const onClickInternal = (event: FormEvent<HTMLElement>) => {
if (disabled) {
return;
}
event.preventDefault();
event.stopPropagation();
onClick(data);
};
return (
<div ref={ref} className={wrapperClassName} aria-label="Menu option" onClick={onClickInternal}>
<div className={cx(styles.optionBody, customStyles.menuOptionBody)}>
<span>{data.label || data.value}</span>
{!hideDescription && data.description && <div className={styles.optionDescription}>{data.description}</div>}
</div>
{data.description && (
<Tooltip content={data.description}>
<Icon name="info-circle" className={customStyles.menuOptionInfoSign} />
</Tooltip>
)}
</div>
);
}
);
MenuOption.displayName = 'MenuOption';
export const getStyles = (menuWidth?: number) => (theme: GrafanaTheme2) => {
return {
menuWrapper: css`
display: flex;
max-height: 650px;
position: absolute;
z-index: ${theme.zIndex.dropdown};
overflow: hidden;
min-width: auto;
`,
menu: css`
min-width: ${theme.spacing(menuWidth || 0)};
& > div {
padding-top: ${theme.spacing(1)};
}
`,
menuLeft: css`
right: 0;
flex-direction: row-reverse;
`,
container: css`
padding: ${theme.spacing(1)};
border: 1px ${theme.colors.border.weak} solid;
border-radius: ${theme.shape.borderRadius(1)};
background-color: ${theme.colors.background.primary};
z-index: ${theme.zIndex.modal};
`,
menuOptionWrapper: css`
padding: ${theme.spacing(0.5)};
`,
menuOptionBody: css`
font-weight: ${theme.typography.fontWeightRegular};
padding: ${theme.spacing(0, 1.5, 0, 0)};
`,
menuOptionDisabled: css`
color: ${theme.colors.text.disabled};
cursor: not-allowed;
`,
menuOptionInfoSign: css`
color: ${theme.colors.text.disabled};
`,
};
};

View File

@@ -0,0 +1,2 @@
export const MENU_MAX_HEIGHT = 300; // max height for the picker's dropdown menu
export const METRIC_PICKER_WIDTH = 360;

View File

@@ -4,3 +4,4 @@ export { AckButton } from './AckButton/AckButton';
export { ExploreButton } from './ExploreButton/ExploreButton';
export { ExecScriptButton } from './ExecScriptButton/ExecScriptButton';
export { ModalController } from './Modal/ModalController';
export { MetricPicker } from './MetricPicker/MetricPicker';