Combobox: Handle error if async fails (#95740)

This commit is contained in:
Joao Silva 2024-11-04 12:08:51 +00:00 committed by GitHub
parent 9d937725ad
commit afcd620d21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 64 additions and 4 deletions

View File

@ -273,6 +273,14 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
); );
}, []); }, []);
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');
}
}, []);
return ( return (
<> <>
<Field <Field
@ -309,6 +317,18 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
/> />
</Field> </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>
<Field label="Compared to AsyncSelect"> <Field label="Compared to AsyncSelect">
<AsyncSelect <AsyncSelect
id="test-async-select" id="test-async-select"

View File

@ -274,5 +274,25 @@ describe('Combobox', () => {
expect(customItem).toBeInTheDocument(); expect(customItem).toBeInTheDocument();
}); });
it('should display message when there is an error loading async options', async () => {
const asyncOptions = jest.fn(() => {
throw new Error('Could not retrieve options');
});
render(<Combobox options={asyncOptions} value={null} onChange={onChangeHandler} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'test');
await act(async () => {
jest.advanceTimersToNextTimer();
});
const emptyMessage = screen.queryByText('An error occurred while loading options.');
expect(emptyMessage).toBeInTheDocument();
});
}); });
}); });

View File

@ -9,6 +9,8 @@ import { t } from '../../utils/i18n';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { AutoSizeInput } from '../Input/AutoSizeInput'; import { AutoSizeInput } from '../Input/AutoSizeInput';
import { Input, Props as InputProps } from '../Input/Input'; import { Input, Props as InputProps } from '../Input/Input';
import { Stack } from '../Layout/Stack/Stack';
import { Text } from '../Text/Text';
import { getComboboxStyles } from './getComboboxStyles'; import { getComboboxStyles } from './getComboboxStyles';
import { useComboboxFloat, OPTION_HEIGHT } from './useComboboxFloat'; import { useComboboxFloat, OPTION_HEIGHT } from './useComboboxFloat';
@ -94,6 +96,7 @@ export const Combobox = <T extends string | number>({
const isAsync = typeof options === 'function'; const isAsync = typeof options === 'function';
const loadOptions = useLatestAsyncCall(isAsync ? options : asyncNoop); // loadOptions isn't called at all if not async const loadOptions = useLatestAsyncCall(isAsync ? options : asyncNoop); // loadOptions isn't called at all if not async
const [asyncLoading, setAsyncLoading] = useState(false); const [asyncLoading, setAsyncLoading] = useState(false);
const [asyncError, setAsyncError] = useState(false);
const [items, setItems] = useState(isAsync ? [] : options); const [items, setItems] = useState(isAsync ? [] : options);
@ -143,10 +146,11 @@ export const Combobox = <T extends string | number>({
.then((opts) => { .then((opts) => {
setItems(customValueOption ? [customValueOption, ...opts] : opts); setItems(customValueOption ? [customValueOption, ...opts] : opts);
setAsyncLoading(false); setAsyncLoading(false);
setAsyncError(false);
}) })
.catch((err) => { .catch((err) => {
if (!(err instanceof StaleResultError)) { if (!(err instanceof StaleResultError)) {
// TODO: handle error setAsyncError(true);
setAsyncLoading(false); setAsyncLoading(false);
} }
}); });
@ -217,12 +221,12 @@ export const Combobox = <T extends string | number>({
.then((options) => { .then((options) => {
setItems(options); setItems(options);
setAsyncLoading(false); setAsyncLoading(false);
setAsyncError(false);
}) })
.catch((err) => { .catch((err) => {
if (!(err instanceof StaleResultError)) { if (!(err instanceof StaleResultError)) {
// TODO: handle error setAsyncError(true);
setAsyncLoading(false); setAsyncLoading(false);
throw err;
} }
}); });
return; return;
@ -307,7 +311,7 @@ export const Combobox = <T extends string | number>({
'aria-labelledby': ariaLabelledBy, 'aria-labelledby': ariaLabelledBy,
})} })}
> >
{isOpen && ( {isOpen && !asyncError && (
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}> <ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => { {rowVirtualizer.getVirtualItems().map((virtualRow) => {
return ( return (
@ -341,6 +345,12 @@ export const Combobox = <T extends string | number>({
})} })}
</ul> </ul>
)} )}
{asyncError && (
<Stack justifyContent="center" alignItems="center" height={8}>
<Icon name="exclamation-triangle" size="md" className={styles.warningIcon} />
<Text color="secondary">{t('combobox.async.error', 'An error occurred while loading options.')}</Text>
</Stack>
)}
</div> </div>
</div> </div>
); );

View File

@ -98,5 +98,9 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
color: theme.colors.text.primary, color: theme.colors.text.primary,
}, },
}), }),
warningIcon: css({
label: 'grafana-select-warning-icon',
color: theme.colors.text.secondary,
}),
}; };
}; };

View File

@ -448,6 +448,9 @@
} }
}, },
"combobox": { "combobox": {
"async": {
"error": "An error occurred while loading options."
},
"clear": { "clear": {
"title": "Clear value" "title": "Clear value"
}, },

View File

@ -448,6 +448,9 @@
} }
}, },
"combobox": { "combobox": {
"async": {
"error": "Åʼn ęřřőř őččūřřęđ ŵĥįľę ľőäđįʼnģ őpŧįőʼnş."
},
"clear": { "clear": {
"title": "Cľęäř väľūę" "title": "Cľęäř väľūę"
}, },