Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Ryan McKinley committed 2025-01-07 15:35:23 +03:00
commit b223778b24
6 files changed
+83 -46

No files matched your search

+1
View File
@@ -4,6 +4,7 @@
ignoreDeps: [
"@types/history", // this can be removed entirely when we upgrade history since v5 exposes types directly
"history", // we should bump this together with react-router-dom (see https://github.com/grafana/grafana/issues/76744)
"react-router", // we should bump this together with history and react-router-dom
"react-router-dom", // we should bump this together with history (see https://github.com/grafana/grafana/issues/76744)
"loader-utils", // v3 requires upstream changes in ngtemplate-loader. ignore, and remove when we remove angular.
"monaco-editor", // due to us exposing this via @grafana/ui/CodeEditor's props bumping can break plugins
@@ -9,6 +9,7 @@ import { Box } from '../Layout/Box/Box';
import { Stack } from '../Layout/Stack/Stack';
import { Portal } from '../Portal/Portal';
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
import { Spinner } from '../Spinner/Spinner';
import { Text } from '../Text/Text';
import { Tooltip } from '../Tooltip';
@@ -34,7 +35,7 @@ interface MultiComboboxBaseProps<T extends string | number> extends Omit<Combobo
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
const { options, placeholder, onChange, value, width } = props;
const { options, placeholder, onChange, value, width, invalid, loading, disabled } = props;
const isAsync = typeof options === 'function';
const selectedItems = useMemo(() => {
@@ -59,9 +60,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen);
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen, invalid, disabled);
const { measureRef, suffixMeasureRef, shownItems } = useMeasureMulti(selectedItems, width);
const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti(
selectedItems,
width,
disabled
);
const isOptionSelected = useCallback(
(item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value),
@@ -157,13 +162,14 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
<div ref={containerRef}>
<div
style={{ width: width === 'auto' ? undefined : width }}
className={multiStyles.wrapper}
className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })}
ref={measureRef}
onClick={() => selectedItems.length > 0 && setIsOpen(!isOpen)}
onClick={() => !disabled && selectedItems.length > 0 && setIsOpen(!isOpen)}
>
<span className={multiStyles.pillWrapper}>
{visibleItems.map((item, index) => (
<ValuePill
disabled={disabled}
onRemove={() => {
removeSelectedItem(item);
}}
@@ -174,7 +180,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
</ValuePill>
))}
{selectedItems.length > shownItems && !isOpen && (
<Box display="flex" direction="row" marginLeft={0.5} gap={1} ref={suffixMeasureRef}>
<Box display="flex" direction="row" marginLeft={0.5} gap={1} ref={counterMeasureRef}>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<Text>...</Text>
<Tooltip
@@ -197,12 +203,18 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
})}
{...getInputProps(
getDropdownProps({
disabled,
preventKeyAction: isOpen,
placeholder: selectedItems.length > 0 ? undefined : placeholder,
onFocus: () => setIsOpen(true),
})
)}
/>
{loading && (
<div className={multiStyles.suffix} ref={suffixMeasureRef}>
<Spinner inline={true} />
</div>
)}
</span>
</div>
<Portal>
@@ -9,28 +9,35 @@ import { IconButton } from '../IconButton/IconButton';
interface ValuePillProps {
children: string;
onRemove: () => void;
disabled?: boolean;
}
export const ValuePill = forwardRef<HTMLSpanElement, ValuePillProps>(({ children, onRemove, ...rest }, ref) => {
const styles = useStyles2(getValuePillStyles);
return (
<span className={styles.wrapper} {...rest} ref={ref}>
<span className={styles.text}>{children}</span>
<span className={styles.separator} />
<IconButton
name="times"
size="md"
aria-label={`Remove ${children}`}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
/>
</span>
);
});
export const ValuePill = forwardRef<HTMLSpanElement, ValuePillProps>(
({ children, onRemove, disabled, ...rest }, ref) => {
const styles = useStyles2(getValuePillStyles, disabled);
return (
<span className={styles.wrapper} {...rest} ref={ref}>
<span className={styles.text}>{children}</span>
{!disabled && (
<>
<span className={styles.separator} />
<IconButton
name="times"
size="md"
aria-label={`Remove ${children}`}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
/>
</>
)}
</span>
);
}
);
const getValuePillStyles = (theme: GrafanaTheme2) => ({
const getValuePillStyles = (theme: GrafanaTheme2, disabled?: boolean) => ({
wrapper: css({
display: 'inline-flex',
gap: theme.spacing(0.5),
@@ -38,6 +45,7 @@ const getValuePillStyles = (theme: GrafanaTheme2) => ({
color: theme.colors.text.primary,
background: theme.colors.background.secondary,
padding: theme.spacing(0.25),
border: disabled ? `1px solid ${theme.colors.border.weak}` : 'none',
fontSize: theme.typography.bodySmall.fontSize,
flexShrink: 0,
minWidth: '50px',
@@ -5,8 +5,13 @@ import { GrafanaTheme2 } from '@grafana/data';
import { getFocusStyles } from '../../themes/mixins';
import { getInputStyles } from '../Input/Input';
export const getMultiComboboxStyles = (theme: GrafanaTheme2, isOpen: boolean) => {
const inputStyles = getInputStyles({ theme });
export const getMultiComboboxStyles = (
theme: GrafanaTheme2,
isOpen: boolean,
invalid?: boolean,
disabled?: boolean
) => {
const inputStyles = getInputStyles({ theme, invalid });
const focusStyles = getFocusStyles(theme);
return {
@@ -52,6 +57,7 @@ export const getMultiComboboxStyles = (theme: GrafanaTheme2, isOpen: boolean) =>
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(0, 1),
border: disabled ? `1px solid ${theme.colors.border.weak}` : 'none',
borderRadius: theme.shape.radius.default,
backgroundColor: theme.colors.background.secondary,
cursor: 'pointer',
@@ -59,5 +65,7 @@ export const getMultiComboboxStyles = (theme: GrafanaTheme2, isOpen: boolean) =>
backgroundColor: theme.colors.action.hover,
},
}),
suffix: inputStyles.suffix,
disabled: inputStyles.inputDisabled,
};
};
@@ -7,26 +7,31 @@ import { ComboboxOption } from './Combobox';
const FONT_SIZE = 12;
const EXTRA_PILL_SIZE = 50;
const EXTRA_PILL_DISABLED_SIZE = 10;
/**
* Updates the number of shown items in the multi combobox based on the available width.
*/
export function useMeasureMulti<T extends string | number>(
selectedItems: Array<ComboboxOption<T>>,
width?: number | 'auto'
width?: number | 'auto',
disabled?: boolean
) {
const [shownItems, setShownItems] = useState<number>(selectedItems.length);
const [measureRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
const [counterMeasureRef, { width: counterWidth }] = useMeasure<HTMLDivElement>();
const [suffixMeasureRef, { width: suffixWidth }] = useMeasure<HTMLDivElement>();
const finalWidth = width && width !== 'auto' ? width : containerWidth;
useEffect(() => {
const maxWidth = finalWidth - suffixWidth;
const maxWidth = finalWidth - counterWidth - suffixWidth;
let currWidth = 0;
for (let i = 0; i < selectedItems.length; i++) {
// Measure text width and add size of padding, separator and close button
currWidth += measureText(selectedItems[i].label || '', FONT_SIZE).width + EXTRA_PILL_SIZE;
currWidth +=
measureText(selectedItems[i].label || '', FONT_SIZE).width +
(disabled ? EXTRA_PILL_DISABLED_SIZE : EXTRA_PILL_SIZE);
if (currWidth > maxWidth) {
// If there is no space for that item, show the current number of items,
// but always show at least 1 item
@@ -38,7 +43,7 @@ export function useMeasureMulti<T extends string | number>(
setShownItems(selectedItems.length);
}
}
}, [finalWidth, suffixWidth, selectedItems, setShownItems]);
}, [finalWidth, counterWidth, suffixWidth, selectedItems, setShownItems, disabled]);
return { measureRef, suffixMeasureRef, shownItems };
return { measureRef, counterMeasureRef, suffixMeasureRef, shownItems };
}
@@ -1,6 +1,6 @@
import * as React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { render, screen, waitFor, within } from 'test/test-utils';
import { render, screen, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
@@ -32,12 +32,15 @@ function renderAlertLabels() {
);
}
function renderLabelsWithSuggestions() {
return render(
async function renderLabelsWithSuggestions() {
const view = render(
<SubFormProviderWrapper>
<LabelsWithSuggestions dataSourceName="grafana" />
</SubFormProviderWrapper>
);
await waitForElementToBeRemoved(() => screen.queryByText('Loading existing labels'));
return view;
}
const grafanaRule = getGrafanaRule(undefined, {
@@ -71,8 +74,10 @@ describe('LabelsField with suggestions', () => {
});
});
jest.retryTimes(2);
it('Should display two dropdowns with the existing labels', async () => {
renderLabelsWithSuggestions();
await renderLabelsWithSuggestions();
await waitFor(() => expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2));
@@ -86,21 +91,19 @@ describe('LabelsField with suggestions', () => {
});
it('Should delete a key-value combination', async () => {
const { user } = renderLabelsWithSuggestions();
const { user } = await renderLabelsWithSuggestions();
await waitFor(() => expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2));
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2);
expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(2);
expect(await screen.findAllByTestId('alertlabel-key-picker')).toHaveLength(2);
expect(await screen.findAllByTestId('alertlabel-value-picker')).toHaveLength(2);
await user.click(screen.getByTestId('delete-label-1'));
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(1);
expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(1);
expect(await screen.findAllByTestId('alertlabel-key-picker')).toHaveLength(1);
expect(await screen.findAllByTestId('alertlabel-value-picker')).toHaveLength(1);
});
it('Should add new key-value dropdowns', async () => {
const { user } = renderLabelsWithSuggestions();
const { user } = await renderLabelsWithSuggestions();
await waitFor(() => expect(screen.getByText('Add more')).toBeVisible());
await user.click(screen.getByText('Add more'));
@@ -119,7 +122,7 @@ describe('LabelsField with suggestions', () => {
});
it('Should be able to write new keys and values using the dropdowns', async () => {
const { user } = renderLabelsWithSuggestions();
const { user } = await renderLabelsWithSuggestions();
await waitFor(() => expect(screen.getByText('Add more')).toBeVisible());
await user.click(screen.getByText('Add more'));
@@ -134,7 +137,7 @@ describe('LabelsField with suggestions', () => {
expect(screen.getByTestId('labelsInSubform-value-2')).toHaveTextContent('value3');
});
it('Should be able to write new keys and values using the dropdowns, case sensitive', async () => {
const { user } = renderLabelsWithSuggestions();
const { user } = await renderLabelsWithSuggestions();
await waitFor(() => expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2));
expect(screen.getByTestId('labelsInSubform-key-0')).toHaveTextContent('key1');