mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
MultiCombobox: add CustomValue
as an option (#99815)
This commit is contained in:
parent
61f5f215ee
commit
74e3beabd0
@ -538,8 +538,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Combobox/Combobox.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
@ -547,6 +546,9 @@ exports[`better eslint`] = {
|
||||
"packages/grafana-ui/src/components/Combobox/ValuePill.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Combobox/useOptions.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
|
@ -237,11 +237,11 @@ describe('Combobox', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} createCustomValue />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.type(input, 'custom value');
|
||||
await userEvent.type(input, 'Use custom value');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(screen.getByDisplayValue('custom value')).toBeInTheDocument();
|
||||
expect(onChangeHandler).toHaveBeenCalledWith(expect.objectContaining({ value: 'custom value' }));
|
||||
expect(screen.getByDisplayValue('Use custom value')).toBeInTheDocument();
|
||||
expect(onChangeHandler).toHaveBeenCalledWith(expect.objectContaining({ value: 'Use custom value' }));
|
||||
});
|
||||
|
||||
it('should provide custom string when all options are numbers', async () => {
|
||||
@ -256,10 +256,10 @@ describe('Combobox', () => {
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} createCustomValue />);
|
||||
const input = screen.getByRole('combobox');
|
||||
|
||||
await userEvent.type(input, 'custom value');
|
||||
await userEvent.type(input, 'Use custom value');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(screen.getByDisplayValue('custom value')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Use custom value')).toBeInTheDocument();
|
||||
expect(typeof onChangeHandler.mock.calls[0][0].value === 'string').toBeTruthy();
|
||||
expect(typeof onChangeHandler.mock.calls[0][0].value === 'number').toBeFalsy();
|
||||
|
||||
@ -411,9 +411,12 @@ describe('Combobox', () => {
|
||||
jest.advanceTimersByTime(500); // Custom value while typing
|
||||
});
|
||||
|
||||
const customItem = screen.queryByRole('option', { name: 'Custom value: fir' });
|
||||
|
||||
const customItem = screen.getByRole('option');
|
||||
const customValue = customItem.getElementsByTagName('span')[0].textContent;
|
||||
const customDescription = customItem.getElementsByTagName('span')[1].textContent;
|
||||
expect(customItem).toBeInTheDocument();
|
||||
expect(customValue).toBe('fir');
|
||||
expect(customDescription).toBe('Use custom value');
|
||||
});
|
||||
|
||||
it('should display message when there is an error loading async options', async () => {
|
||||
|
@ -135,12 +135,10 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
|
||||
if (!optionMatchingInput) {
|
||||
const customValueOption = {
|
||||
label: t('combobox.custom-value.label', 'Custom value: ') + inputValue,
|
||||
label: inputValue,
|
||||
// Type casting needed to make this work when T is a number
|
||||
value: inputValue as unknown as T,
|
||||
/* TODO: Add this back when we do support descriptions and have need for it
|
||||
description: t('combobox.custom-value.create', 'Create custom value'),
|
||||
*/
|
||||
value: inputValue as T,
|
||||
description: t('combobox.custom-value.description', 'Use custom value'),
|
||||
};
|
||||
|
||||
itemsToSet = items.slice(0);
|
||||
|
@ -128,6 +128,40 @@ describe('MultiCombobox', () => {
|
||||
expect(await screen.findByText('d')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be able to set custom value', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
const onChange = jest.fn();
|
||||
render(<MultiCombobox options={options} value={[]} onChange={onChange} createCustomValue />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.type(input, 'D');
|
||||
await user.keyboard('{arrowdown}{enter}');
|
||||
expect(onChange).toHaveBeenCalledWith([{ label: 'D', value: 'D', description: 'Use custom value' }]);
|
||||
});
|
||||
|
||||
it('should be able to add custom value to the selected options', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
const onChange = jest.fn();
|
||||
render(<MultiCombobox options={options} value={['a', 'c']} onChange={onChange} createCustomValue />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.type(input, 'D');
|
||||
await user.keyboard('{arrowdown}{enter}');
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ value: 'a' },
|
||||
{ value: 'c' },
|
||||
{ label: 'D', value: 'D', description: 'Use custom value' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove value when clicking on the close icon of the pill', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
|
@ -37,8 +37,19 @@ 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 { placeholder, onChange, value, width, enableAllOption, invalid, disabled, minWidth, maxWidth, isClearable } =
|
||||
props;
|
||||
const {
|
||||
placeholder,
|
||||
onChange,
|
||||
value,
|
||||
width,
|
||||
enableAllOption,
|
||||
invalid,
|
||||
disabled,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
isClearable,
|
||||
createCustomValue = false,
|
||||
} = props;
|
||||
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
@ -55,7 +66,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
}, [inputValue]);
|
||||
|
||||
// Handle async options and the 'All' option
|
||||
const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options);
|
||||
const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options, createCustomValue);
|
||||
const options = useMemo(() => {
|
||||
// Only add the 'All' option if there's more than 1 option
|
||||
const addAllOption = enableAllOption && baseOptions.length > 1;
|
||||
@ -202,14 +213,12 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
const filteredSet = new Set(realOptions.map((item) => item.value));
|
||||
newSelectedItems = selectedItems.filter((item) => !filteredSet.has(item.value));
|
||||
}
|
||||
|
||||
setSelectedItems(newSelectedItems);
|
||||
} else if (newSelectedItem && isOptionSelected(newSelectedItem)) {
|
||||
removeSelectedItem(newSelectedItem);
|
||||
} else if (newSelectedItem) {
|
||||
addSelectedItem(newSelectedItem);
|
||||
}
|
||||
|
||||
break;
|
||||
case useCombobox.stateChangeTypes.InputChange:
|
||||
setInputValue(newInputValue ?? '');
|
||||
|
@ -20,9 +20,6 @@ export function itemToString<T extends string | number>(item?: ComboboxOption<T>
|
||||
if (item == null) {
|
||||
return '';
|
||||
}
|
||||
if (item.label?.startsWith('Custom value: ')) {
|
||||
return item.value.toString();
|
||||
}
|
||||
return item.label ?? item.value.toString();
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { debounce } from 'lodash';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { t } from '../../utils/i18n';
|
||||
|
||||
import { itemFilter } from './filter';
|
||||
import { ComboboxOption } from './types';
|
||||
import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall';
|
||||
@ -20,7 +22,7 @@ const asyncNoop = () => Promise.resolve([]);
|
||||
* - function to call when user types (to filter, or call async fn)
|
||||
* - loading and error states
|
||||
*/
|
||||
export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T>) {
|
||||
export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T>, createCustomValue: boolean) {
|
||||
const isAsync = typeof rawOptions === 'function';
|
||||
|
||||
const loadOptions = useLatestAsyncCall(isAsync ? rawOptions : asyncNoop);
|
||||
@ -56,6 +58,27 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
|
||||
// told it for async options loading anyway.
|
||||
const [userTypedSearch, setUserTypedSearch] = useState('');
|
||||
|
||||
const addCustomValue = useCallback(
|
||||
(opts: Array<ComboboxOption<T>>) => {
|
||||
let currentOptions: Array<ComboboxOption<T>> = opts;
|
||||
if (createCustomValue && userTypedSearch) {
|
||||
const customValueExists = opts.some((opt) => opt.value === userTypedSearch);
|
||||
if (!customValueExists) {
|
||||
currentOptions = [
|
||||
{
|
||||
label: userTypedSearch,
|
||||
value: userTypedSearch as T,
|
||||
description: t('combobox.custom-value.description', 'Use custom value'),
|
||||
},
|
||||
...currentOptions,
|
||||
];
|
||||
}
|
||||
}
|
||||
return currentOptions;
|
||||
},
|
||||
[createCustomValue, userTypedSearch]
|
||||
);
|
||||
|
||||
const updateOptions = useCallback(
|
||||
(inputValue: string) => {
|
||||
if (!isAsync) {
|
||||
@ -71,12 +94,15 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
|
||||
);
|
||||
|
||||
const finalOptions = useMemo(() => {
|
||||
let currentOptions = [];
|
||||
if (isAsync) {
|
||||
return asyncOptions;
|
||||
currentOptions = addCustomValue(asyncOptions);
|
||||
} else {
|
||||
return rawOptions.filter(itemFilter(userTypedSearch));
|
||||
currentOptions = addCustomValue(rawOptions.filter(itemFilter(userTypedSearch)));
|
||||
}
|
||||
}, [rawOptions, asyncOptions, isAsync, userTypedSearch]);
|
||||
|
||||
return currentOptions;
|
||||
}, [isAsync, addCustomValue, asyncOptions, rawOptions, userTypedSearch]);
|
||||
|
||||
return { options: finalOptions, updateOptions, asyncLoading, asyncError };
|
||||
}
|
||||
|
@ -709,7 +709,7 @@
|
||||
"title": "Clear value"
|
||||
},
|
||||
"custom-value": {
|
||||
"label": "Custom value: "
|
||||
"description": "Use custom value"
|
||||
},
|
||||
"options": {
|
||||
"no-found": "No options found."
|
||||
|
@ -709,7 +709,7 @@
|
||||
"title": "Cľęäř väľūę"
|
||||
},
|
||||
"custom-value": {
|
||||
"label": "Cūşŧőm väľūę: "
|
||||
"description": "Ůşę čūşŧőm väľūę"
|
||||
},
|
||||
"options": {
|
||||
"no-found": "Ńő őpŧįőʼnş ƒőūʼnđ."
|
||||
|
Loading…
Reference in New Issue
Block a user