From 58bbe17d8434a28eefa25b1af0675a748d4d108a Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Fri, 25 Jun 2021 11:05:36 +0100 Subject: [PATCH] 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 --- .../src/components/Select/Select.story.tsx | 14 +++++- .../src/components/Select/SelectBase.test.tsx | 50 +++++++++++++++++++ .../src/components/Select/SelectBase.tsx | 6 ++- .../grafana-ui/src/components/Select/types.ts | 8 ++- 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/grafana-ui/src/components/Select/Select.story.tsx b/packages/grafana-ui/src/components/Select/Select.story.tsx index e3937ef2717..73119ffd0f7 100644 --- a/packages/grafana-ui/src/components/Select/Select.story.tsx +++ b/packages/grafana-ui/src/components/Select/Select.story.tsx @@ -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 = (args) => { value={value} onChange={(v) => { setValue(v); + action('onChange')(v); }} prefix={getPrefix(args.icon)} {...args} @@ -113,6 +114,7 @@ export const BasicSelectPlainValue: Story = (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 = { value: kebabCase(v), label: v }; + const customValue: SelectableValue = { value: v, label: v }; setCustomOptions([...customOptions, customValue]); setValue(customValue); + action('onCreateOption')(v); }} prefix={getPrefix(args.icon)} {...args} diff --git a/packages/grafana-ui/src/components/Select/SelectBase.test.tsx b/packages/grafana-ui/src/components/Select/SelectBase.test.tsx index 20e560001f4..331fc7cc912 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.test.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.test.tsx @@ -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['filterOption'] = (option, value) => option.value === value; + const valueIsStrictlyNotEqual: SelectBaseProps['isValidNewOption'] = (newOption, _, options) => + options.every(({ value }) => value !== newOption); + + const spy = jest.fn(); + render( + + ); + + 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', + }) + ); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index 47c438132b3..b4ca80442fc 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -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({ tabSelectsValue = true, value, width, + isValidNewOption, }: SelectBaseProps) { const theme = useTheme2(); const styles = getSelectStyles(theme); @@ -149,7 +150,7 @@ export function SelectBase({ let ReactSelectComponent = ReactSelect; - const creatableProps: any = {}; + const creatableProps: ComponentProps = {}; let asyncSelectProps: any = {}; let selectedValue; if (isMulti && loadOptions) { @@ -218,6 +219,7 @@ export function SelectBase({ 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 diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index 5af8e377873..f18fd37daea 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -19,7 +19,7 @@ export interface SelectCommonProps { components?: any; defaultValue?: any; disabled?: boolean; - filterOption?: (option: SelectableValue, searchQuery: string) => boolean; + filterOption?: (option: SelectableValue, searchQuery: string) => boolean; /** Function for formatting the text that is displayed when creating a new value*/ formatCreateLabel?: (input: string) => string; getOptionLabel?: (item: SelectableValue) => React.ReactNode; @@ -64,6 +64,12 @@ export interface SelectCommonProps { /** 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 | null, + options: Readonly>> + ) => boolean; } export interface SelectAsyncProps {