|
|
|
@ -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<T extends string | number = string> = ComponentProps<typeof Combobox<T>> & { |
|
|
|
|
type PropsAndCustomArgs<T extends string | number = string> = ComboboxProps<T> & { |
|
|
|
|
numberOfOptions: number; |
|
|
|
|
}; |
|
|
|
|
type Story<T extends string | number = string> = StoryObj<PropsAndCustomArgs<T>>; |
|
|
|
|
|
|
|
|
|
const meta: Meta<PropsAndCustomArgs> = { |
|
|
|
|
title: 'Forms/Combobox', |
|
|
|
@ -63,257 +63,200 @@ const meta: Meta<PropsAndCustomArgs> = { |
|
|
|
|
], |
|
|
|
|
value: 'banana', |
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
render: (args) => <BasicWithState {...args} />, |
|
|
|
|
decorators: [InDevDecorator], |
|
|
|
|
}; |
|
|
|
|
export default meta; |
|
|
|
|
|
|
|
|
|
const loadOptionsAction = action('options called'); |
|
|
|
|
const onChangeAction = action('onChange called'); |
|
|
|
|
|
|
|
|
|
const BaseCombobox: StoryFn<PropsAndCustomArgs> = (args) => { |
|
|
|
|
const [dynamicArgs, setArgs] = useArgs(); |
|
|
|
|
|
|
|
|
|
const BasicWithState: StoryFn<PropsAndCustomArgs> = (args) => { |
|
|
|
|
const [value, setValue] = useState<string | null>(); |
|
|
|
|
return ( |
|
|
|
|
<Field label="Test input" description="Input with a few options"> |
|
|
|
|
<Combobox |
|
|
|
|
id="test-combobox" |
|
|
|
|
{...args} |
|
|
|
|
value={value} |
|
|
|
|
onChange={(val: ComboboxOption | null) => { |
|
|
|
|
// 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); |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
</Field> |
|
|
|
|
); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
type Story = StoryObj<typeof Combobox>; |
|
|
|
|
|
|
|
|
|
export const Basic: Story = {}; |
|
|
|
|
|
|
|
|
|
export async function generateOptions(amount: number): Promise<ComboboxOption[]> { |
|
|
|
|
return Array.from({ length: amount }, (_, index) => ({ |
|
|
|
|
label: 'Option ' + index, |
|
|
|
|
value: index.toString(), |
|
|
|
|
})); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const ManyOptionsStory: StoryFn<PropsAndCustomArgs<string>> = ({ numberOfOptions, ...args }) => { |
|
|
|
|
const [value, setValue] = useState<string | null>(null); |
|
|
|
|
const [options, setOptions] = useState<ComboboxOption[]>([]); |
|
|
|
|
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 ( |
|
|
|
|
<Combobox |
|
|
|
|
{...rest} |
|
|
|
|
loading={isLoading} |
|
|
|
|
options={options} |
|
|
|
|
value={value} |
|
|
|
|
onChange={(opt: ComboboxOption | null) => { |
|
|
|
|
setValue(opt?.value || null); |
|
|
|
|
action('onChange')(opt); |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
); |
|
|
|
|
export const Basic: Story = { |
|
|
|
|
render: BaseCombobox, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
export const AutoSize: StoryObj<PropsAndCustomArgs> = { |
|
|
|
|
export const AutoSize: Story = { |
|
|
|
|
args: { |
|
|
|
|
width: 'auto', |
|
|
|
|
minWidth: 5, |
|
|
|
|
maxWidth: 200, |
|
|
|
|
}, |
|
|
|
|
render: BaseCombobox, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
export const ManyOptions: StoryObj<PropsAndCustomArgs> = { |
|
|
|
|
export const CustomValue: Story = { |
|
|
|
|
args: { |
|
|
|
|
numberOfOptions: 1e5, |
|
|
|
|
options: undefined, |
|
|
|
|
value: undefined, |
|
|
|
|
createCustomValue: true, |
|
|
|
|
}, |
|
|
|
|
render: ManyOptionsStory, |
|
|
|
|
render: BaseCombobox, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
export const CustomValue: StoryObj<PropsAndCustomArgs> = { |
|
|
|
|
export const ManyOptions: Story = { |
|
|
|
|
args: { |
|
|
|
|
createCustomValue: true, |
|
|
|
|
numberOfOptions: 1e5, |
|
|
|
|
options: undefined, |
|
|
|
|
value: undefined, |
|
|
|
|
}, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const loadOptionsAction = action('loadOptions called'); |
|
|
|
|
const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => { |
|
|
|
|
// Combobox
|
|
|
|
|
const [selectedOption, setSelectedOption] = useState<ComboboxOption<string> | null>(null); |
|
|
|
|
|
|
|
|
|
// AsyncSelect
|
|
|
|
|
const [asyncSelectValue, setAsyncSelectValue] = useState<SelectableValue<string> | null>(null); |
|
|
|
|
|
|
|
|
|
// 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! })) |
|
|
|
|
); |
|
|
|
|
}, []); |
|
|
|
|
|
|
|
|
|
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 ( |
|
|
|
|
<> |
|
|
|
|
<Field |
|
|
|
|
label="Options with labels" |
|
|
|
|
description="This tests when options have both a label and a value. Consumers are required to pass in a full ComboboxOption as a value with a label" |
|
|
|
|
> |
|
|
|
|
render: ({ numberOfOptions, ...args }: PropsAndCustomArgs) => { |
|
|
|
|
const [dynamicArgs, setArgs] = useArgs(); |
|
|
|
|
const [options, setOptions] = useState<ComboboxOption[]>([]); |
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
setTimeout(() => { |
|
|
|
|
generateOptions(numberOfOptions).then((options) => { |
|
|
|
|
setOptions(options); |
|
|
|
|
setArgs({ value: options[5].value }); |
|
|
|
|
}); |
|
|
|
|
}, 1000); |
|
|
|
|
}, [numberOfOptions, setArgs]); |
|
|
|
|
|
|
|
|
|
const { onChange, ...rest } = args; |
|
|
|
|
return ( |
|
|
|
|
<Field label="Test input" description={options.length ? 'Input with a few options' : 'Preparing options...'}> |
|
|
|
|
<Combobox |
|
|
|
|
{...rest} |
|
|
|
|
id="test-combobox-one" |
|
|
|
|
placeholder="Select an option" |
|
|
|
|
options={loadOptionsWithLabels} |
|
|
|
|
value={selectedOption} |
|
|
|
|
onChange={(val: ComboboxOption | null) => { |
|
|
|
|
action('onChange')(val); |
|
|
|
|
setSelectedOption(val); |
|
|
|
|
{...dynamicArgs} |
|
|
|
|
options={options} |
|
|
|
|
onChange={(value: ComboboxOption | null) => { |
|
|
|
|
setArgs({ value: value?.value || null }); |
|
|
|
|
onChangeAction(value); |
|
|
|
|
}} |
|
|
|
|
createCustomValue={args.createCustomValue} |
|
|
|
|
/> |
|
|
|
|
</Field> |
|
|
|
|
); |
|
|
|
|
}, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
function loadOptionsWithLabels(inputValue: string) { |
|
|
|
|
loadOptionsAction(inputValue); |
|
|
|
|
return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<Field |
|
|
|
|
label="Options without labels" |
|
|
|
|
description="Or without labels, where consumer can just pass in a raw scalar value Value" |
|
|
|
|
label='Asynbc options fn returns objects like { label: "Option 69", value: "69" }' |
|
|
|
|
description="Search for 'break' to see an error" |
|
|
|
|
> |
|
|
|
|
<Combobox |
|
|
|
|
{...args} |
|
|
|
|
id="test-combobox-two" |
|
|
|
|
placeholder="Select an option" |
|
|
|
|
options={loadOptionsOnlyValues} |
|
|
|
|
value={selectedOption?.value ?? null} |
|
|
|
|
{...dynamicArgs} |
|
|
|
|
onChange={(val: ComboboxOption | null) => { |
|
|
|
|
action('onChange')(val); |
|
|
|
|
setSelectedOption(val); |
|
|
|
|
onChangeAction(val); |
|
|
|
|
setArgs({ value: val }); |
|
|
|
|
}} |
|
|
|
|
createCustomValue={args.createCustomValue} |
|
|
|
|
/> |
|
|
|
|
</Field> |
|
|
|
|
); |
|
|
|
|
}, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
<Field label="Async with error" description="An odd number of characters throws an error"> |
|
|
|
|
<Combobox |
|
|
|
|
id="test-combobox-error" |
|
|
|
|
placeholder="Select an option" |
|
|
|
|
options={loadOptionsWithErrors} |
|
|
|
|
value={selectedOption} |
|
|
|
|
onChange={(val) => { |
|
|
|
|
action('onChange')(val); |
|
|
|
|
setSelectedOption(val); |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
</Field> |
|
|
|
|
function loadOptionsOnlyValues(inputValue: string) { |
|
|
|
|
loadOptionsAction(inputValue); |
|
|
|
|
return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`).then((options) => |
|
|
|
|
options.map((opt) => ({ value: opt.label! })) |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
<Field label="Compared to AsyncSelect"> |
|
|
|
|
<AsyncSelect |
|
|
|
|
id="test-async-select" |
|
|
|
|
placeholder="Select an option" |
|
|
|
|
loadOptions={loadOptionsWithLabels} |
|
|
|
|
value={asyncSelectValue} |
|
|
|
|
defaultOptions |
|
|
|
|
onChange={(val) => { |
|
|
|
|
action('onChange')(val); |
|
|
|
|
setAsyncSelectValue(val); |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
</Field> |
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
|
<Field label="Async with error" description="An odd number of characters throws an error"> |
|
|
|
|
return ( |
|
|
|
|
<Field |
|
|
|
|
label='Async options fn returns objects like { value: "69" }' |
|
|
|
|
description="Search for 'break' to see an error" |
|
|
|
|
> |
|
|
|
|
<Combobox |
|
|
|
|
{...args} |
|
|
|
|
id="test-combobox-error" |
|
|
|
|
placeholder="Select an option" |
|
|
|
|
options={loadOptionsWithErrors} |
|
|
|
|
value={selectedOption} |
|
|
|
|
onChange={(val: ComboboxOption | null) => { |
|
|
|
|
action('onChange')(val); |
|
|
|
|
setSelectedOption(val); |
|
|
|
|
{...dynamicArgs} |
|
|
|
|
onChange={(value: ComboboxOption | null) => { |
|
|
|
|
onChangeAction(value); |
|
|
|
|
setArgs({ value: value }); |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
</Field> |
|
|
|
|
</> |
|
|
|
|
); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
export const Async: StoryObj<PropsAndCustomArgs> = { |
|
|
|
|
render: AsyncStory, |
|
|
|
|
); |
|
|
|
|
}, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const noop = () => {}; |
|
|
|
|
const PositioningTestStory: StoryFn<PropsAndCustomArgs> = (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 ( |
|
|
|
|
<div |
|
|
|
|
style={{ |
|
|
|
|
display: 'flex', |
|
|
|
|
flexDirection: 'column', |
|
|
|
|
justifyContent: 'space-between', |
|
|
|
|
flex: 1, |
|
|
|
|
}} |
|
|
|
|
> |
|
|
|
|
<Combobox {...args} placeholder={`${pos} top`} options={args.options} value={null} onChange={noop} /> |
|
|
|
|
<Combobox {...args} placeholder={`${pos} middle`} options={args.options} value={null} onChange={noop} /> |
|
|
|
|
<Combobox {...args} placeholder={`${pos} bottom`} options={args.options} value={null} onChange={noop} /> |
|
|
|
|
</div> |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<div |
|
|
|
|
style={{ |
|
|
|
|
display: 'flex', |
|
|
|
|
flexDirection: 'column', |
|
|
|
|
flexDirection: 'row', |
|
|
|
|
|
|
|
|
|
// approx the height of the dev alert, and three margins. exact doesn't matter
|
|
|
|
|
minHeight: 'calc(100vh - (105px + 16px + 16px + 16px))', |
|
|
|
|
justifyContent: 'space-between', |
|
|
|
|
flex: 1, |
|
|
|
|
gap: 32, |
|
|
|
|
}} |
|
|
|
|
> |
|
|
|
|
<Combobox {...args} placeholder={`${pos} top`} options={args.options} value={null} onChange={noop} /> |
|
|
|
|
<Combobox {...args} placeholder={`${pos} middle`} options={args.options} value={null} onChange={noop} /> |
|
|
|
|
<Combobox {...args} placeholder={`${pos} bottom`} options={args.options} value={null} onChange={noop} /> |
|
|
|
|
{renderColumnOfComboboxes('Left')} |
|
|
|
|
{renderColumnOfComboboxes('Middle')} |
|
|
|
|
{renderColumnOfComboboxes('Right')} |
|
|
|
|
</div> |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<div |
|
|
|
|
style={{ |
|
|
|
|
display: 'flex', |
|
|
|
|
flexDirection: 'row', |
|
|
|
|
|
|
|
|
|
// approx the height of the dev alert, and three margins. exact doesn't matter
|
|
|
|
|
minHeight: 'calc(100vh - (105px + 16px + 16px + 16px))', |
|
|
|
|
justifyContent: 'space-between', |
|
|
|
|
gap: 32, |
|
|
|
|
}} |
|
|
|
|
> |
|
|
|
|
{renderColumnOfComboboxes('Left')} |
|
|
|
|
{renderColumnOfComboboxes('Middle')} |
|
|
|
|
{renderColumnOfComboboxes('Right')} |
|
|
|
|
</div> |
|
|
|
|
); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
export const PositioningTest: StoryObj<PropsAndCustomArgs> = { |
|
|
|
|
render: PositioningTestStory, |
|
|
|
|
}, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
export default meta; |
|
|
|
|
|
|
|
|
|
function InDevDecorator(Story: React.ElementType) { |
|
|
|
|
return ( |
|
|
|
|
<div> |
|
|
|
@ -327,28 +270,3 @@ function InDevDecorator(Story: React.ElementType) { |
|
|
|
|
</div> |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let fakeApiOptions: Array<ComboboxOption<string>>; |
|
|
|
|
async function fakeSearchAPI(urlString: string): Promise<Array<ComboboxOption<string>>> { |
|
|
|
|
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<Array<ComboboxOption<string>>>((resolve) => { |
|
|
|
|
setTimeout(() => resolve(filteredOptions), delay); |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|