MultiCombobox: add CustomValue as an option (#99815)

This commit is contained in:
Laura Fernández 2025-02-03 11:41:54 +01:00 committed by GitHub
parent 61f5f215ee
commit 74e3beabd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 97 additions and 28 deletions

View File

@ -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"]
],

View File

@ -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 () => {

View File

@ -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);

View File

@ -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' },

View File

@ -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 ?? '');

View File

@ -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();
}

View File

@ -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 };
}

View File

@ -709,7 +709,7 @@
"title": "Clear value"
},
"custom-value": {
"label": "Custom value: "
"description": "Use custom value"
},
"options": {
"no-found": "No options found."

View File

@ -709,7 +709,7 @@
"title": "Cľęäř väľūę"
},
"custom-value": {
"label": "Cūşŧőm väľūę: "
"description": "Ůşę čūşŧőm väľūę"
},
"options": {
"no-found": "Ńő őpŧįőʼnş ƒőūʼnđ."