mirror of
https://github.com/grafana/grafana.git
synced 2025-09-19 14:32:52 +08:00
Select: Preserve value when allowCustomValue
is set (#87843)
* initial working poc with some better types * move logic inside SelectBase * add unit tests * cleaner logic * simpler * update comments * more comments * use onMenuClose * undo changes to cleanValue * fix unit tests --------- Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
@ -682,11 +682,7 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
[0, 0, 0, "Do not use any type assertions.", "4"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
|
||||||
[0, 0, 0, "Do not use any type assertions.", "6"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "9"]
|
|
||||||
],
|
],
|
||||||
"packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx:5381": [
|
"packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||||
|
@ -174,35 +174,20 @@ describe('PromQueryBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows hints for histogram metrics', async () => {
|
it('shows hints for histogram metrics', async () => {
|
||||||
const { container } = setup({
|
setup({
|
||||||
metric: 'histogram_metric_bucket',
|
metric: 'histogram_metric_bucket',
|
||||||
labels: [],
|
labels: [],
|
||||||
operations: [],
|
operations: [],
|
||||||
});
|
});
|
||||||
await openMetricSelect(container);
|
|
||||||
await userEvent.click(screen.getByText('histogram_metric_bucket'));
|
|
||||||
await waitFor(() => expect(screen.getByText('hint: add histogram_quantile')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('hint: add histogram_quantile')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows hints for counter metrics', async () => {
|
it('shows hints for counter metrics', async () => {
|
||||||
const { container } = setup({
|
setup({
|
||||||
metric: 'histogram_metric_sum',
|
metric: 'histogram_metric_sum',
|
||||||
labels: [],
|
labels: [],
|
||||||
operations: [],
|
operations: [],
|
||||||
});
|
});
|
||||||
await openMetricSelect(container);
|
|
||||||
await userEvent.click(screen.getByText('histogram_metric_sum'));
|
|
||||||
await waitFor(() => expect(screen.getByText('hint: add rate')).toBeInTheDocument());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows hints for counter metrics', async () => {
|
|
||||||
const { container } = setup({
|
|
||||||
metric: 'histogram_metric_sum',
|
|
||||||
labels: [],
|
|
||||||
operations: [],
|
|
||||||
});
|
|
||||||
await openMetricSelect(container);
|
|
||||||
await userEvent.click(screen.getByText('histogram_metric_sum'));
|
|
||||||
await waitFor(() => expect(screen.getByText('hint: add rate')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('hint: add rate')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -215,7 +200,7 @@ describe('PromQueryBuilder', () => {
|
|||||||
for (let i = 0; i < 25; i++) {
|
for (let i = 0; i < 25; i++) {
|
||||||
data.series.push(new MutableDataFrame());
|
data.series.push(new MutableDataFrame());
|
||||||
}
|
}
|
||||||
const { container } = setup(
|
setup(
|
||||||
{
|
{
|
||||||
metric: 'histogram_metric_sum',
|
metric: 'histogram_metric_sum',
|
||||||
labels: [],
|
labels: [],
|
||||||
@ -223,8 +208,6 @@ describe('PromQueryBuilder', () => {
|
|||||||
},
|
},
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
await openMetricSelect(container);
|
|
||||||
await userEvent.click(screen.getByText('histogram_metric_sum'));
|
|
||||||
await waitFor(() => expect(screen.getAllByText(/hint:/)).toHaveLength(2));
|
await waitFor(() => expect(screen.getAllByText(/hint:/)).toHaveLength(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -64,6 +64,39 @@ describe('SelectBase', () => {
|
|||||||
expect(screen.queryByText('Test label')).not.toBeInTheDocument();
|
expect(screen.queryByText('Test label')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with custom values', () => {
|
||||||
|
it('allows editing a custom SelectableValue', async () => {
|
||||||
|
render(
|
||||||
|
<SelectBase
|
||||||
|
onChange={onChangeHandler}
|
||||||
|
allowCustomValue
|
||||||
|
value={{
|
||||||
|
label: 'my custom value',
|
||||||
|
value: 'my custom value',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('combobox'));
|
||||||
|
await userEvent.type(screen.getByRole('combobox'), '{backspace}{backspace}{enter}');
|
||||||
|
expect(onChangeHandler).toHaveBeenCalled();
|
||||||
|
expect(onChangeHandler.mock.calls[0][0]).toEqual(
|
||||||
|
expect.objectContaining({ label: 'my custom val', value: 'my custom val' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows editing a custom basic value', async () => {
|
||||||
|
render(<SelectBase onChange={onChangeHandler} allowCustomValue value="my custom value" />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('combobox'));
|
||||||
|
await userEvent.type(screen.getByRole('combobox'), '{backspace}{backspace}{enter}');
|
||||||
|
expect(onChangeHandler).toHaveBeenCalled();
|
||||||
|
expect(onChangeHandler.mock.calls[0][0]).toEqual(
|
||||||
|
expect.objectContaining({ label: 'my custom val', value: 'my custom val' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('when openMenuOnFocus prop', () => {
|
describe('when openMenuOnFocus prop', () => {
|
||||||
describe('is provided', () => {
|
describe('is provided', () => {
|
||||||
it('opens on focus', () => {
|
it('opens on focus', () => {
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import React, { ComponentProps, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { ComponentProps, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { default as ReactSelect, IndicatorsContainerProps, Props as ReactSelectProps } from 'react-select';
|
import {
|
||||||
|
default as ReactSelect,
|
||||||
|
IndicatorsContainerProps,
|
||||||
|
Props as ReactSelectProps,
|
||||||
|
ClearIndicatorProps,
|
||||||
|
} from 'react-select';
|
||||||
import { default as ReactAsyncSelect } from 'react-select/async';
|
import { default as ReactAsyncSelect } from 'react-select/async';
|
||||||
import { default as AsyncCreatable } from 'react-select/async-creatable';
|
import { default as AsyncCreatable } from 'react-select/async-creatable';
|
||||||
import Creatable from 'react-select/creatable';
|
import Creatable from 'react-select/creatable';
|
||||||
@ -19,7 +24,7 @@ import { MultiValueContainer, MultiValueRemove } from './MultiValue';
|
|||||||
import { SelectContainer } from './SelectContainer';
|
import { SelectContainer } from './SelectContainer';
|
||||||
import { SelectMenu, SelectMenuOptions, VirtualizedSelectMenu } from './SelectMenu';
|
import { SelectMenu, SelectMenuOptions, VirtualizedSelectMenu } from './SelectMenu';
|
||||||
import { SelectOptionGroup } from './SelectOptionGroup';
|
import { SelectOptionGroup } from './SelectOptionGroup';
|
||||||
import { SingleValue } from './SingleValue';
|
import { Props, SingleValue } from './SingleValue';
|
||||||
import { ValueContainer } from './ValueContainer';
|
import { ValueContainer } from './ValueContainer';
|
||||||
import { getSelectStyles } from './getSelectStyles';
|
import { getSelectStyles } from './getSelectStyles';
|
||||||
import { useCustomSelectStyles } from './resetSelectStyles';
|
import { useCustomSelectStyles } from './resetSelectStyles';
|
||||||
@ -199,6 +204,8 @@ export function SelectBase<T, Rest = {}>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [internalInputValue, setInternalInputValue] = useState('');
|
||||||
|
|
||||||
const commonSelectProps = {
|
const commonSelectProps = {
|
||||||
'aria-label': ariaLabel,
|
'aria-label': ariaLabel,
|
||||||
'data-testid': dataTestid,
|
'data-testid': dataTestid,
|
||||||
@ -268,12 +275,41 @@ export function SelectBase<T, Rest = {}>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (allowCustomValue) {
|
if (allowCustomValue) {
|
||||||
ReactSelectComponent = Creatable as any;
|
ReactSelectComponent = Creatable;
|
||||||
creatableProps.allowCreateWhileLoading = allowCreateWhileLoading;
|
creatableProps.allowCreateWhileLoading = allowCreateWhileLoading;
|
||||||
creatableProps.formatCreateLabel = formatCreateLabel ?? defaultFormatCreateLabel;
|
creatableProps.formatCreateLabel = formatCreateLabel ?? defaultFormatCreateLabel;
|
||||||
creatableProps.onCreateOption = onCreateOption;
|
creatableProps.onCreateOption = onCreateOption;
|
||||||
creatableProps.createOptionPosition = createOptionPosition;
|
creatableProps.createOptionPosition = createOptionPosition;
|
||||||
creatableProps.isValidNewOption = isValidNewOption;
|
creatableProps.isValidNewOption = isValidNewOption;
|
||||||
|
|
||||||
|
// code needed to allow editing a custom value once entered
|
||||||
|
// we only want to do this for single selects, not multi
|
||||||
|
if (!isMulti) {
|
||||||
|
creatableProps.inputValue = internalInputValue;
|
||||||
|
creatableProps.onMenuOpen = () => {
|
||||||
|
// make sure we call the base onMenuOpen if it exists
|
||||||
|
commonSelectProps.onMenuOpen?.();
|
||||||
|
|
||||||
|
// restore the input state to the selected value
|
||||||
|
setInternalInputValue(selectedValue?.[0]?.label ?? '');
|
||||||
|
};
|
||||||
|
creatableProps.onInputChange = (val, actionMeta) => {
|
||||||
|
// make sure we call the base onInputChange
|
||||||
|
commonSelectProps.onInputChange(val, actionMeta);
|
||||||
|
|
||||||
|
// update the input value state on change since we're explicitly controlling it
|
||||||
|
if (actionMeta.action === 'input-change') {
|
||||||
|
setInternalInputValue(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
creatableProps.onMenuClose = () => {
|
||||||
|
// make sure we call the base onMenuClose if it exists
|
||||||
|
commonSelectProps.onMenuClose?.();
|
||||||
|
|
||||||
|
// clear the input state on menu close, else react-select won't show the SingleValue component correctly
|
||||||
|
setInternalInputValue('');
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instead of having AsyncSelect, as a separate component we render ReactAsyncSelect
|
// Instead of having AsyncSelect, as a separate component we render ReactAsyncSelect
|
||||||
@ -300,7 +336,7 @@ export function SelectBase<T, Rest = {}>({
|
|||||||
IndicatorSeparator: IndicatorSeparator,
|
IndicatorSeparator: IndicatorSeparator,
|
||||||
Control: CustomControl,
|
Control: CustomControl,
|
||||||
Option: SelectMenuOptions,
|
Option: SelectMenuOptions,
|
||||||
ClearIndicator(props: any) {
|
ClearIndicator(props: ClearIndicatorProps) {
|
||||||
const { clearValue } = props;
|
const { clearValue } = props;
|
||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
@ -330,7 +366,7 @@ export function SelectBase<T, Rest = {}>({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
DropdownIndicator: DropdownIndicator,
|
DropdownIndicator: DropdownIndicator,
|
||||||
SingleValue(props: any) {
|
SingleValue(props: Props<T>) {
|
||||||
return <SingleValue {...props} isDisabled={disabled} />;
|
return <SingleValue {...props} isDisabled={disabled} />;
|
||||||
},
|
},
|
||||||
SelectContainer,
|
SelectContainer,
|
||||||
|
@ -111,6 +111,7 @@ describe('useCreatableSelectPersistedBehaviour', () => {
|
|||||||
await userEvent.click(input);
|
await userEvent.click(input);
|
||||||
|
|
||||||
// we expect 2 elemnts having "Option 2": the input itself and the option.
|
// we expect 2 elemnts having "Option 2": the input itself and the option.
|
||||||
expect(screen.getAllByText('Option 2')).toHaveLength(2);
|
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('combobox')).toHaveValue('Option 2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -157,6 +157,7 @@ describe('SearchForm', () => {
|
|||||||
await user.click(asyncOperationSelect);
|
await user.click(asyncOperationSelect);
|
||||||
jest.advanceTimersByTime(3000);
|
jest.advanceTimersByTime(3000);
|
||||||
|
|
||||||
|
await user.clear(asyncOperationSelect);
|
||||||
await user.type(asyncOperationSelect, '$');
|
await user.type(asyncOperationSelect, '$');
|
||||||
const operationOption = await screen.findByText('$operation');
|
const operationOption = await screen.findByText('$operation');
|
||||||
expect(operationOption).toBeDefined();
|
expect(operationOption).toBeDefined();
|
||||||
|
Reference in New Issue
Block a user