Combobox: refactor stories (#99482)

refactor stories
pull/98926/head
Josh Hunt 6 months ago committed by GitHub
parent df024793d8
commit 7cb6845d44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 350
      packages/grafana-ui/src/components/Combobox/Combobox.story.tsx
  2. 4
      packages/grafana-ui/src/components/Combobox/Combobox.tsx
  3. 2
      packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx
  4. 39
      packages/grafana-ui/src/components/Combobox/storyUtils.ts

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

@ -84,7 +84,9 @@ export type AutoSizeConditionals =
maxWidth?: never;
};
type ComboboxProps<T extends string | number> = ComboboxBaseProps<T> & AutoSizeConditionals & ClearableConditionals<T>;
export type ComboboxProps<T extends string | number> = ComboboxBaseProps<T> &
AutoSizeConditionals &
ClearableConditionals<T>;
const noop = () => {};
const asyncNoop = () => Promise.resolve([]);

@ -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<typeof MultiCombobox> = {
title: 'Forms/MultiCombobox',

@ -0,0 +1,39 @@
import { ComboboxOption } from './Combobox';
let fakeApiOptions: Array<ComboboxOption<string>>;
export async function fakeSearchAPI(urlString: string): Promise<Array<ComboboxOption<string>>> {
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<Array<ComboboxOption<string>>>((resolve) => {
setTimeout(() => resolve(filteredOptions), delay);
});
}
export async function generateOptions(amount: number): Promise<ComboboxOption[]> {
return Array.from({ length: amount }, (_, index) => ({
label: 'Option ' + index,
value: index.toString(),
}));
}
Loading…
Cancel
Save