diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx index e6c239f5bc5..2fa3bde2bd3 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx @@ -1,19 +1,19 @@ import { action } from '@storybook/addon-actions'; +import { useArgs } from '@storybook/preview-api'; import { Meta, StoryFn, StoryObj } from '@storybook/react'; -import React, { ComponentProps, useCallback, useEffect, useState } from 'react'; - -import { SelectableValue } from '@grafana/data'; +import React, { useEffect, useState } from 'react'; import { Alert } from '../Alert/Alert'; import { Field } from '../Forms/Field'; -import { AsyncSelect } from '../Select/Select'; -import { Combobox, ComboboxOption } from './Combobox'; +import { Combobox, ComboboxOption, ComboboxProps } from './Combobox'; import mdx from './Combobox.mdx'; +import { fakeSearchAPI, generateOptions } from './storyUtils'; -type PropsAndCustomArgs = ComponentProps> & { +type PropsAndCustomArgs = ComboboxProps & { numberOfOptions: number; }; +type Story = StoryObj>; const meta: Meta = { title: 'Forms/Combobox', @@ -63,257 +63,200 @@ const meta: Meta = { ], value: 'banana', }, - - render: (args) => , decorators: [InDevDecorator], }; +export default meta; + +const loadOptionsAction = action('options called'); +const onChangeAction = action('onChange called'); + +const BaseCombobox: StoryFn = (args) => { + const [dynamicArgs, setArgs] = useArgs(); -const BasicWithState: StoryFn = (args) => { - const [value, setValue] = useState(); return ( { - // TODO: Figure out how to update value on args - setValue(val?.value || null); - action('onChange')(val); + {...dynamicArgs} + onChange={(value: ComboboxOption | null) => { + setArgs({ value: value?.value || null }); + onChangeAction(value); }} /> ); }; -type Story = StoryObj; - -export const Basic: Story = {}; - -export async function generateOptions(amount: number): Promise { - return Array.from({ length: amount }, (_, index) => ({ - label: 'Option ' + index, - value: index.toString(), - })); -} - -const ManyOptionsStory: StoryFn> = ({ numberOfOptions, ...args }) => { - const [value, setValue] = useState(null); - const [options, setOptions] = useState([]); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - setTimeout(() => { - generateOptions(numberOfOptions).then((options) => { - setIsLoading(false); - setOptions(options); - setValue(options[5].value); - }); - }, 1000); - }, [numberOfOptions]); - - const { onChange, ...rest } = args; - return ( - { - setValue(opt?.value || null); - action('onChange')(opt); - }} - /> - ); +export const Basic: Story = { + render: BaseCombobox, }; -export const AutoSize: StoryObj = { +export const AutoSize: Story = { args: { width: 'auto', minWidth: 5, maxWidth: 200, }, + render: BaseCombobox, }; -export const ManyOptions: StoryObj = { +export const CustomValue: Story = { + args: { + createCustomValue: true, + }, + render: BaseCombobox, +}; + +export const ManyOptions: Story = { args: { numberOfOptions: 1e5, options: undefined, value: undefined, }, - render: ManyOptionsStory, -}; + render: ({ numberOfOptions, ...args }: PropsAndCustomArgs) => { + const [dynamicArgs, setArgs] = useArgs(); + const [options, setOptions] = useState([]); -export const CustomValue: StoryObj = { - args: { - createCustomValue: true, + useEffect(() => { + setTimeout(() => { + generateOptions(numberOfOptions).then((options) => { + setOptions(options); + setArgs({ value: options[5].value }); + }); + }, 1000); + }, [numberOfOptions, setArgs]); + + const { onChange, ...rest } = args; + return ( + + { + setArgs({ value: value?.value || null }); + onChangeAction(value); + }} + /> + + ); }, }; -const loadOptionsAction = action('loadOptions called'); -const AsyncStory: StoryFn = (args) => { - // Combobox - const [selectedOption, setSelectedOption] = useState | null>(null); +function loadOptionsWithLabels(inputValue: string) { + loadOptionsAction(inputValue); + return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`); +} - // AsyncSelect - const [asyncSelectValue, setAsyncSelectValue] = useState | null>(null); +export const AsyncOptionsWithLabels: Story = { + name: 'Async - values + labels', + args: { + options: loadOptionsWithLabels, + value: { label: 'Option 69', value: '69' }, + placeholder: 'Select an option', + }, + render: (args: PropsAndCustomArgs) => { + const [dynamicArgs, setArgs] = useArgs(); - // This simulates a kind of search API call - const loadOptionsWithLabels = useCallback((inputValue: string) => { - loadOptionsAction(inputValue); - return fakeSearchAPI(`http://example.com/search?query=${inputValue}`); - }, []); - - const loadOptionsOnlyValues = useCallback((inputValue: string) => { - return fakeSearchAPI(`http://example.com/search?query=${inputValue}`).then((options) => - options.map((opt) => ({ value: opt.label! })) + return ( + + { + onChangeAction(val); + setArgs({ value: val }); + }} + /> + ); - }, []); - - const loadOptionsWithErrors = useCallback((inputValue: string) => { - if (inputValue.length % 2 === 0) { - return fakeSearchAPI(`http://example.com/search?query=${inputValue}`); - } else { - throw new Error('Could not retrieve options'); - } - }, []); - - const { onChange, ...rest } = args; - - return ( - <> - - { - action('onChange')(val); - setSelectedOption(val); - }} - createCustomValue={args.createCustomValue} - /> - - - - { - action('onChange')(val); - setSelectedOption(val); - }} - createCustomValue={args.createCustomValue} - /> - - - - { - action('onChange')(val); - setSelectedOption(val); - }} - /> - - - - { - action('onChange')(val); - setAsyncSelectValue(val); - }} - /> - - - - { - action('onChange')(val); - setSelectedOption(val); - }} - /> - - - ); + }, }; -export const Async: StoryObj = { - render: AsyncStory, +function loadOptionsOnlyValues(inputValue: string) { + loadOptionsAction(inputValue); + return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`).then((options) => + options.map((opt) => ({ value: opt.label! })) + ); +} + +export const AsyncOptionsWithOnlyValues: Story = { + name: 'Async - values only', + args: { + options: loadOptionsOnlyValues, + value: { value: 'Option 69' }, + placeholder: 'Select an option', + }, + render: (args: PropsAndCustomArgs) => { + const [dynamicArgs, setArgs] = useArgs(); + + return ( + + { + onChangeAction(value); + setArgs({ value: value }); + }} + /> + + ); + }, }; const noop = () => {}; -const PositioningTestStory: StoryFn = (args) => { - if (typeof args.options === 'function') { - throw new Error('This story does not support async options'); - } - function renderColumnOfComboboxes(pos: string) { +export const PositioningTest: Story = { + render: (args: PropsAndCustomArgs) => { + if (typeof args.options === 'function') { + throw new Error('This story does not support async options'); + } + + function renderColumnOfComboboxes(pos: string) { + return ( +
+ + + +
+ ); + } + return (
- - - + {renderColumnOfComboboxes('Left')} + {renderColumnOfComboboxes('Middle')} + {renderColumnOfComboboxes('Right')}
); - } - - return ( -
- {renderColumnOfComboboxes('Left')} - {renderColumnOfComboboxes('Middle')} - {renderColumnOfComboboxes('Right')} -
- ); + }, }; -export const PositioningTest: StoryObj = { - render: PositioningTestStory, -}; - -export default meta; - function InDevDecorator(Story: React.ElementType) { return (
@@ -327,28 +270,3 @@ function InDevDecorator(Story: React.ElementType) {
); } - -let fakeApiOptions: Array>; -async function fakeSearchAPI(urlString: string): Promise>> { - const searchParams = new URL(urlString).searchParams; - - if (!fakeApiOptions) { - fakeApiOptions = await generateOptions(1000); - } - - const searchQuery = searchParams.get('query')?.toLowerCase(); - - if (!searchQuery || searchQuery.length === 0) { - return Promise.resolve(fakeApiOptions.slice(0, 10)); - } - - const filteredOptions = Promise.resolve( - fakeApiOptions.filter((opt) => opt.label?.toLowerCase().includes(searchQuery)) - ); - - const delay = searchQuery.length % 2 === 0 ? 200 : 1000; - - return new Promise>>((resolve) => { - setTimeout(() => resolve(filteredOptions), delay); - }); -} diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index df3ffd50a80..30f24775be6 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -84,7 +84,9 @@ export type AutoSizeConditionals = maxWidth?: never; }; -type ComboboxProps = ComboboxBaseProps & AutoSizeConditionals & ClearableConditionals; +export type ComboboxProps = ComboboxBaseProps & + AutoSizeConditionals & + ClearableConditionals; const noop = () => {}; const asyncNoop = () => Promise.resolve([]); diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx index 3af44f61d3c..e247536c8ce 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx @@ -3,8 +3,8 @@ import { useArgs, useEffect, useState } from '@storybook/preview-api'; import type { Meta, StoryFn, StoryObj } from '@storybook/react'; import { ComboboxOption } from './Combobox'; -import { generateOptions } from './Combobox.story'; import { MultiCombobox } from './MultiCombobox'; +import { generateOptions } from './storyUtils'; const meta: Meta = { title: 'Forms/MultiCombobox', diff --git a/packages/grafana-ui/src/components/Combobox/storyUtils.ts b/packages/grafana-ui/src/components/Combobox/storyUtils.ts new file mode 100644 index 00000000000..809ac047cb1 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/storyUtils.ts @@ -0,0 +1,39 @@ +import { ComboboxOption } from './Combobox'; + +let fakeApiOptions: Array>; +export async function fakeSearchAPI(urlString: string): Promise>> { + const searchParams = new URL(urlString).searchParams; + + const errorOnQuery = searchParams.get('errorOnQuery')?.toLowerCase(); + const searchQuery = searchParams.get('query')?.toLowerCase(); + + if (errorOnQuery === searchQuery) { + throw new Error('An error occurred (because it was asked for)'); + } + + if (!fakeApiOptions) { + fakeApiOptions = await generateOptions(1000); + console.log('fakeApiOptions', fakeApiOptions); + } + + if (!searchQuery || searchQuery.length === 0) { + return Promise.resolve(fakeApiOptions.slice(0, 24)); + } + + const filteredOptions = Promise.resolve( + fakeApiOptions.filter((opt) => opt.label?.toLowerCase().includes(searchQuery)) + ); + + const delay = searchQuery.length % 2 === 0 ? 200 : 1000; + + return new Promise>>((resolve) => { + setTimeout(() => resolve(filteredOptions), delay); + }); +} + +export async function generateOptions(amount: number): Promise { + return Array.from({ length: amount }, (_, index) => ({ + label: 'Option ' + index, + value: index.toString(), + })); +}