Standardization across Zabbix UI components (#2141)
## Summary Throughout Zabbix we did not have a uniform UI - some drop-down were using `Select` others `Combobox` others a custom one that we created. Some had placeholders and others did not. This PR aims to standardize our Zabbix UI across our query, variable and config editors ## Detailed summary - Migrate from `Select` to `Combobox` -> `Select` component is deprecated - Migrate from `HorizontalGroup` to `Stack` -> `HorizontalGroup` is also deprecated - Remove use of "custom" dropdown `MetricPickerMenu` in favor of `Combobox` ensuring uniformity across our drop-down and removing maintenance overhead for us down the line - Standardize placeholders across all inputs <img width="630" height="243" alt="Screenshot 2025-12-17 at 1 13 45 PM" src="https://github.com/user-attachments/assets/9382057e-b443-4474-a9c8-850086d7f3d4" /> <img width="691" height="256" alt="Screenshot 2025-12-17 at 1 14 05 PM" src="https://github.com/user-attachments/assets/a05ff2af-8603-4752-8d12-337dc381c0fd" /> ## Why To have a clean and standard UI and remove use of UI deprecated packages. ## How to test - Query Editor: - By creating a new query in a dashboard or Explore and interacting with all the different query types and drop-downs - All drop-downs should be searchable and have placeholders - Config Editor: - By going to a datasource and ensuring that the dropdown for Datasource (when DB connection is enabled) and Auth type are responsive and working as expected) Fixes: https://github.com/orgs/grafana/projects/457/views/40?pane=issue&itemId=3740545830&issue=grafana%7Coss-big-tent-squad%7C139
This commit is contained in:
committed by
GitHub
parent
ce4a8d3e19
commit
127367464e
@@ -1,130 +1,35 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { FormEvent, useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { ClickOutsideWrapper, Input, Spinner, useStyles2 } from '@grafana/ui';
|
||||
import { MetricPickerMenu } from './MetricPickerMenu';
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useRef } from 'react';
|
||||
import { Combobox, ComboboxOption } from '@grafana/ui';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { isRegex } from '../../datasource/utils';
|
||||
|
||||
export interface Props {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
isLoading?: boolean;
|
||||
options: Array<SelectableValue<string>>;
|
||||
options: Array<ComboboxOption<string>>;
|
||||
width?: number;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const MetricPicker = ({ value, options, isLoading, width, onChange }: Props) => {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState(value);
|
||||
const [filteredOptions, setFilteredOptions] = useState(options);
|
||||
const [selectedOptionIdx, setSelectedOptionIdx] = useState(-1);
|
||||
const [offset] = useState({ vertical: 0, horizontal: 0 });
|
||||
export const MetricPicker = ({ value, placeholder, options, isLoading, width, onChange }: Props) => {
|
||||
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 onBlurInternal = () => {
|
||||
onChange(query);
|
||||
};
|
||||
|
||||
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={onBlurInternal}
|
||||
onMouseDown={onOpen}
|
||||
suffix={isLoading && <Spinner />}
|
||||
width={width}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{isOpen && (
|
||||
<MetricPickerMenu
|
||||
options={filteredOptions}
|
||||
onSelect={onMenuOptionSelect}
|
||||
offset={offset}
|
||||
minWidth={width}
|
||||
selected={selectedOptionIdx}
|
||||
/>
|
||||
)}
|
||||
</ClickOutsideWrapper>
|
||||
<Combobox<string>
|
||||
width={width}
|
||||
value={value}
|
||||
options={options ?? []}
|
||||
onChange={onMenuOptionSelect}
|
||||
loading={isLoading}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
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: Array<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) => {
|
||||
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};
|
||||
`,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user