Grafana-UI: allow custom validity checks for creatable selects (#36118)

* add actions handling to Select in storybook

* Grafana-UI: allow custom validity checks for creatable selects
This commit is contained in:
Giordano Ricci 2021-06-25 11:05:36 +01:00 committed by GitHub
parent b8b90ec74f
commit 58bbe17d84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 73 additions and 5 deletions

View File

@ -5,10 +5,10 @@ import { Icon, Select, AsyncSelect, MultiSelect, AsyncMultiSelect } from '@grafa
import { getAvailableIcons, IconName } from '../../types';
import { SelectCommonProps } from './types';
import { Meta, Story } from '@storybook/react';
import { kebabCase } from 'lodash';
import { generateOptions } from './mockOptions';
import mdx from './Select.mdx';
import { auto } from '@popperjs/core';
import { action } from '@storybook/addon-actions';
export default {
title: 'Forms/Select',
@ -94,6 +94,7 @@ export const Basic: Story<StoryProps> = (args) => {
value={value}
onChange={(v) => {
setValue(v);
action('onChange')(v);
}}
prefix={getPrefix(args.icon)}
{...args}
@ -113,6 +114,7 @@ export const BasicSelectPlainValue: Story<StoryProps> = (args) => {
value={value}
onChange={(v) => {
setValue(v.value);
action('onChange')(v);
}}
prefix={getPrefix(args.icon)}
{...args}
@ -145,6 +147,7 @@ export const SelectWithOptionDescriptions: Story = (args) => {
value={value}
onChange={(v) => {
setValue(v.value);
action('onChange')(v);
}}
prefix={getPrefix(args.icon)}
{...args}
@ -187,6 +190,7 @@ export const MultiSelectWithOptionGroups: Story = (args) => {
value={value}
onChange={(v) => {
setValue(v.map((v: any) => v.value));
action('onChange')(v);
}}
prefix={getPrefix(args.icon)}
{...args}
@ -205,6 +209,7 @@ export const MultiSelectBasic: Story = (args) => {
value={value}
onChange={(v) => {
setValue(v);
action('onChange')(v);
}}
prefix={getPrefix(args.icon)}
{...args}
@ -228,6 +233,7 @@ export const MultiSelectAsync: Story = (args) => {
value={value}
onChange={(v) => {
setValue(v);
action('onChange')(v);
}}
prefix={getPrefix(args.icon)}
{...args}
@ -248,6 +254,7 @@ export const BasicSelectAsync: Story = (args) => {
value={value}
onChange={(v) => {
setValue(v);
action('onChange')(v);
}}
prefix={getPrefix(args.icon)}
{...args}
@ -266,6 +273,7 @@ export const AutoMenuPlacement: Story = (args) => {
value={value}
onChange={(v) => {
setValue(v);
action('onChange')(v);
}}
prefix={getPrefix(args.icon)}
{...args}
@ -289,12 +297,14 @@ export const CustomValueCreation: Story = (args) => {
value={value}
onChange={(v) => {
setValue(v);
action('onChange')(v);
}}
allowCustomValue={args.allowCustomValue}
onCreateOption={(v) => {
const customValue: SelectableValue<string> = { value: kebabCase(v), label: v };
const customValue: SelectableValue<string> = { value: v, label: v };
setCustomOptions([...customOptions, customValue]);
setValue(customValue);
action('onCreateOption')(v);
}}
prefix={getPrefix(args.icon)}
{...args}

View File

@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import selectEvent from 'react-select-event';
import { SelectBase } from './SelectBase';
import { SelectBaseProps } from './types';
import { SelectableValue } from '@grafana/data';
import { MultiValueContainer } from './MultiValue';
@ -213,4 +214,53 @@ describe('SelectBase', () => {
});
});
});
describe('When allowCustomValue is set to true', () => {
it('Should allow creating a new option', async () => {
const valueIsStrictlyEqual: SelectBaseProps<string>['filterOption'] = (option, value) => option.value === value;
const valueIsStrictlyNotEqual: SelectBaseProps<string>['isValidNewOption'] = (newOption, _, options) =>
options.every(({ value }) => value !== newOption);
const spy = jest.fn();
render(
<SelectBase
onChange={spy}
isOpen
allowCustomValue
filterOption={valueIsStrictlyEqual}
isValidNewOption={valueIsStrictlyNotEqual}
/>
);
const textBox = screen.getByRole('textbox');
userEvent.type(textBox, 'NOT AN OPTION');
let creatableOption = screen.getByLabelText('Select option');
expect(creatableOption).toBeInTheDocument();
expect(creatableOption).toHaveTextContent('Create: NOT AN OPTION');
userEvent.click(creatableOption);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
label: 'NOT AN OPTION',
value: 'NOT AN OPTION',
})
);
// Should also create options in a case-insensitive way.
userEvent.type(textBox, 'not an option');
creatableOption = screen.getByLabelText('Select option');
expect(creatableOption).toBeInTheDocument();
expect(creatableOption).toHaveTextContent('Create: not an option');
userEvent.click(creatableOption);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
label: 'not an option',
value: 'not an option',
})
);
});
});
});

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { ComponentProps, useCallback } from 'react';
import { default as ReactSelect } from 'react-select';
import Creatable from 'react-select/creatable';
import { default as ReactAsyncSelect } from 'react-select/async';
@ -134,6 +134,7 @@ export function SelectBase<T>({
tabSelectsValue = true,
value,
width,
isValidNewOption,
}: SelectBaseProps<T>) {
const theme = useTheme2();
const styles = getSelectStyles(theme);
@ -149,7 +150,7 @@ export function SelectBase<T>({
let ReactSelectComponent = ReactSelect;
const creatableProps: any = {};
const creatableProps: ComponentProps<typeof Creatable> = {};
let asyncSelectProps: any = {};
let selectedValue;
if (isMulti && loadOptions) {
@ -218,6 +219,7 @@ export function SelectBase<T>({
ReactSelectComponent = Creatable as any;
creatableProps.formatCreateLabel = formatCreateLabel ?? ((input: string) => `Create: ${input}`);
creatableProps.onCreateOption = onCreateOption;
creatableProps.isValidNewOption = isValidNewOption;
}
// Instead of having AsyncSelect, as a separate component we render ReactAsyncSelect

View File

@ -19,7 +19,7 @@ export interface SelectCommonProps<T> {
components?: any;
defaultValue?: any;
disabled?: boolean;
filterOption?: (option: SelectableValue, searchQuery: string) => boolean;
filterOption?: (option: SelectableValue<T>, searchQuery: string) => boolean;
/** Function for formatting the text that is displayed when creating a new value*/
formatCreateLabel?: (input: string) => string;
getOptionLabel?: (item: SelectableValue<T>) => React.ReactNode;
@ -64,6 +64,12 @@ export interface SelectCommonProps<T> {
/** Sets the width to a multiple of 8px. Should only be used with inline forms. Setting width of the container is preferred in other cases.*/
width?: number;
isOptionDisabled?: () => boolean;
/** allowCustomValue must be enabled. Determines whether the "create new" option should be displayed based on the current input value, select value and options array. */
isValidNewOption?: (
inputValue: string,
value: SelectableValue<T> | null,
options: Readonly<Array<SelectableValue<T>>>
) => boolean;
}
export interface SelectAsyncProps<T> {