From 2318b3fd70055322c5e0ea2f28514a6886c29a98 Mon Sep 17 00:00:00 2001 From: Jordan Young Date: Tue, 9 Apr 2024 15:38:43 -0400 Subject: [PATCH] feat(mui-autocomplete): add AsyncAutocomplete component --- packages/autocomplete/README.md | 82 ++++++++++++- packages/autocomplete/src/index.ts | 1 + .../src/lib/AsyncAutocomplete.test.tsx | 86 ++++++++++++++ .../src/lib/AsyncAutocomplete.tsx | 74 ++++++++++++ .../src/lib/Autocomplete.stories.tsx | 110 +++++++++++++++++- .../autocomplete/src/lib/Autocomplete.tsx | 13 +++ 6 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 packages/autocomplete/src/lib/AsyncAutocomplete.test.tsx create mode 100644 packages/autocomplete/src/lib/AsyncAutocomplete.tsx diff --git a/packages/autocomplete/README.md b/packages/autocomplete/README.md index 5a1e5823f..d7dc86b58 100644 --- a/packages/autocomplete/README.md +++ b/packages/autocomplete/README.md @@ -34,7 +34,7 @@ yarn add @availity/element #### NPM -_This package has a few peer dependencies. Add `@mui/material`, `@emotion/react`, @availity/mui-form-utils, & @availity/mui-textfield to your project if not already installed._ +_This package has a few peer dependencies. Add `@mui/material`, `@emotion/react`, `@availity/mui-form-utils`, & `@availity/mui-textfield` to your project if not already installed._ ```bash npm install @availity/mui-autocomplete @@ -50,8 +50,26 @@ yarn add @availity/mui-autocomplete #### Import through @availity/element +The `Autcomplete` component can be used standalone or with a form state library like [react-hook-form](https://react-hook-form.com/). + +`Autocomplete` uses the `TextField` component to render the input. You must pass your field related props: `label`, `helperText`, `error`, etc. to the the `FieldProps` prop. + ```tsx import { Autocomplete } from '@availity/element'; + +const MyAutocomplete = () => { + return ( + value.label} + FieldProps={{ label: 'My Autocomplete Field', helperText: 'Text that helps the user' }} + /> + ); +}; ``` #### Direct import @@ -59,3 +77,65 @@ import { Autocomplete } from '@availity/element'; ```tsx import { Autocomplete } from '@availity/mui-autocomplete'; ``` + +#### Usage with `react-hook-form` + +```tsx +import { useForm, Controller } from 'react-hook-form'; +import { Autocomplete, Button } from '@availity/element'; + +const Form = () => { + const { handleSubmit } = useForm(); + + const onSubmit = (values) => { + console.log(values); + }; + + return ( +
+ { + return ( + { + if (reason === 'clear') { + onChange(null); + } + onChange(value); + }} + onBlur={onBlur} + FieldProps={{ label: 'Dropdown', helperText: 'This is helper text', placeholder: 'Value' }} + options={['Bulbasaur', 'Squirtle', 'Charmander']} + value={value || null} + /> + ); + }} + /> + + + ); +}; +``` + +#### `AsyncAutocomplete` Usage + +An `AsyncAutocomplete` component is exported for use cases that require fetching paginated results from an api. You will need to use the `loadOptions` prop. The `loadOptions` function will be called when the user scrolls to the bottom of the dropdown. It will be passed the current page and limit. The `limit` prop controls what is passed to `loadOptions` and is defaulted to `50`. The `loadOptions` function must return an object that has an array of `options` and a `hasMore` property. `hasMore` tells the `AsyncAutocomplete` component whether or not it should call `loadOptions` again. The returned `options` will be concatenated to the existing options array. + +```tsx +import { Autocomplete } from '@availity/element'; + +const Example = () => { + const loadOptions = async (page: number) => { + const response = await callApi(page); + + return { + options: repsonse.data, + hasMore: response.totalCount > response.count, + }; + }; + + return ; +}; +``` diff --git a/packages/autocomplete/src/index.ts b/packages/autocomplete/src/index.ts index 753b5621f..ecf532c6f 100644 --- a/packages/autocomplete/src/index.ts +++ b/packages/autocomplete/src/index.ts @@ -1 +1,2 @@ export * from './lib/Autocomplete'; +export * from './lib/AsyncAutocomplete'; diff --git a/packages/autocomplete/src/lib/AsyncAutocomplete.test.tsx b/packages/autocomplete/src/lib/AsyncAutocomplete.test.tsx new file mode 100644 index 000000000..2d42c0f0a --- /dev/null +++ b/packages/autocomplete/src/lib/AsyncAutocomplete.test.tsx @@ -0,0 +1,86 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { AsyncAutocomplete } from './AsyncAutocomplete'; + +describe('AsyncAutocomplete', () => { + test('should render successfully', () => { + const { getByLabelText } = render( + ({ + options: ['1', '2', '3'], + hasMore: false, + })} + /> + ); + expect(getByLabelText('Test')).toBeTruthy(); + }); + + test('options should be available', async () => { + const loadOptions = () => + Promise.resolve({ + options: [{ label: 'Option 1' }], + getOptionLabel: (option: { label: string }) => option.label, + hasMore: false, + }); + + render(); + + const input = screen.getByRole('combobox'); + fireEvent.click(input); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + + waitFor(() => { + expect(screen.getByText('Option 1')).toBeDefined(); + }); + + fireEvent.click(await screen.findByText('Option 1')); + + waitFor(() => { + expect(screen.getByText('Option 1')).toBeDefined(); + }); + }); + + test('should call loadOptions when scroll to the bottom', async () => { + const loadOptions = jest.fn(); + loadOptions.mockResolvedValueOnce({ + options: [ + { label: 'Option 1' }, + { label: 'Option 2' }, + { label: 'Option 3' }, + { label: 'Option 4' }, + { label: 'Option 5' }, + { label: 'Option 6' }, + ], + hasMore: true, + }); + render(); + + const input = screen.getByRole('combobox'); + fireEvent.click(input); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + + await waitFor(() => { + expect(screen.getByText('Option 1')).toBeDefined(); + }); + + expect(loadOptions).toHaveBeenCalled(); + expect(loadOptions).toHaveBeenCalledTimes(1); + expect(loadOptions).toHaveBeenCalledWith(0, 50); + + loadOptions.mockResolvedValueOnce({ + options: [{ label: 'Option 7' }], + hasMore: false, + }); + + await act(async () => { + const options = await screen.findByRole('listbox'); + fireEvent.scroll(options, { target: { scrollTop: options.scrollHeight } }); + }); + + await waitFor(() => { + expect(loadOptions).toHaveBeenCalled(); + expect(loadOptions).toHaveBeenCalledTimes(2); + expect(loadOptions).toHaveBeenLastCalledWith(1, 50); + }); + }); +}); diff --git a/packages/autocomplete/src/lib/AsyncAutocomplete.tsx b/packages/autocomplete/src/lib/AsyncAutocomplete.tsx new file mode 100644 index 000000000..fb176e447 --- /dev/null +++ b/packages/autocomplete/src/lib/AsyncAutocomplete.tsx @@ -0,0 +1,74 @@ +import { useState, useEffect } from 'react'; +import type { ChipTypeMap } from '@mui/material/Chip'; + +import { Autocomplete, AutocompleteProps } from './Autocomplete'; + +export interface AsyncAutocompleteProps< + Option, + Multiple extends boolean | undefined, + DisableClearable extends boolean | undefined, + FreeSolo extends boolean | undefined, + ChipComponent extends React.ElementType = ChipTypeMap['defaultComponent'] +> extends Omit, 'options'> { + /** Function that returns a promise with options and hasMore */ + loadOptions: (page: number, limit: number) => Promise<{ options: Option[]; hasMore: boolean }>; + /** The number of options to request from the api + * @default 50 */ + limit?: number; +} + +export const AsyncAutocomplete = < + Option, + Multiple extends boolean | undefined = false, + DisableClearable extends boolean | undefined = false, + FreeSolo extends boolean | undefined = false, + ChipComponent extends React.ElementType = ChipTypeMap['defaultComponent'] +>({ + loadOptions, + limit = 50, + ...rest +}: AsyncAutocompleteProps) => { + const [page, setPage] = useState(0); + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + + useEffect(() => { + const getInitialOptions = async () => { + setLoading(true); + const result = await loadOptions(page, limit); + setOptions(result.options); + setHasMore(result.hasMore); + setPage((prev) => prev + 1); + setLoading(false); + }; + + if (!loading && hasMore && page === 0) { + getInitialOptions(); + } + }, [page, loading, loadOptions]); + + return ( + { + const listboxNode = event.currentTarget; + const difference = listboxNode.scrollHeight - (listboxNode.scrollTop + listboxNode.clientHeight); + + // Only fetch if we are near the bottom, not already fetching, and there are more results + if (difference <= 5 && !loading && hasMore) { + setLoading(true); + const result = await loadOptions(page, limit); + setOptions([...options, ...result.options]); + setHasMore(result.hasMore); + setPage((prev) => prev + 1); + setLoading(false); + } + }, + }} + /> + ); +}; diff --git a/packages/autocomplete/src/lib/Autocomplete.stories.tsx b/packages/autocomplete/src/lib/Autocomplete.stories.tsx index e52cbafdb..b806258be 100644 --- a/packages/autocomplete/src/lib/Autocomplete.stories.tsx +++ b/packages/autocomplete/src/lib/Autocomplete.stories.tsx @@ -1,7 +1,7 @@ // Each exported component in the package should have its own stories file - import type { Meta, StoryObj } from '@storybook/react'; import { Autocomplete } from './Autocomplete'; +import { AsyncAutocomplete } from './AsyncAutocomplete'; const meta: Meta = { title: 'Components/Autocomplete/Autocomplete', @@ -42,3 +42,111 @@ export const _Multi: StoryObj = { multiple: true, }, }; + +type Org = { + id: string; + name: string; +}; + +const organizations: Org[] = [ + { + id: '1', + name: 'Org 1', + }, + { + id: '2', + name: 'Org 2', + }, + { + id: '3', + name: 'Org 3', + }, + { + id: '4', + name: 'Org 4', + }, + { + id: '5', + name: 'Org 5', + }, + { + id: '6', + name: 'Org 6', + }, + { + id: '7', + name: 'Org 7', + }, + { + id: '8', + name: 'Org 8', + }, + { + id: '9', + name: 'Org 9', + }, + { + id: '10', + name: 'Org 10', + }, + { + id: '11', + name: 'Org 11', + }, + { + id: '12', + name: 'Org 12', + }, + { + id: '13', + name: 'Org 13', + }, + { + id: '14', + name: 'Org 14', + }, + { + id: '15', + name: 'Org 15', + }, +]; + +async function sleep(duration = 2500) { + await new Promise((resolve) => setTimeout(resolve, duration)); +} + +const getResults = (page: number, limit: number) => { + const offset = page * limit; + const orgs = organizations.slice(page * offset, page * offset + limit); + + return { + totalCount: organizations.length, + offset, + limit, + orgs, + count: orgs.length, + }; +}; + +const loadOptions = async (page: number, limit: number) => { + await sleep(1000); + + const { orgs, totalCount, offset } = getResults(page, limit); + + return { + options: orgs, + hasMore: offset + limit < totalCount, + }; +}; + +export const _Async: StoryObj = { + render: (args) => { + return ; + }, + args: { + FieldProps: { label: 'Async Select', helperText: 'Helper Text', fullWidth: false }, + getOptionLabel: (val: Org) => val.name, + loadOptions, + limit: 10, + }, +}; diff --git a/packages/autocomplete/src/lib/Autocomplete.tsx b/packages/autocomplete/src/lib/Autocomplete.tsx index d6268fe47..ffc61907e 100644 --- a/packages/autocomplete/src/lib/Autocomplete.tsx +++ b/packages/autocomplete/src/lib/Autocomplete.tsx @@ -5,6 +5,7 @@ import { AutocompleteRenderInputParams, AutocompletePropsSizeOverrides, } from '@mui/material/Autocomplete'; +import CircularProgress from '@mui/material/CircularProgress'; import { default as MuiIconButton, IconButtonProps as MuiIconButtonProps } from '@mui/material/IconButton'; import { ChipTypeMap } from '@mui/material/Chip'; import { OverridableStringUnion } from '@mui/types'; @@ -50,6 +51,10 @@ const PopupIndicatorWrapper = forwardRef( )); +const progressSx = { marginRight: '.5rem' }; + +const LoadingIndicator = () => ; + export const Autocomplete = < T, Multiple extends boolean | undefined = false, @@ -71,6 +76,14 @@ export const Autocomplete = < InputProps: { ...FieldProps?.InputProps, ...params?.InputProps, + endAdornment: props.loading ? ( + <> + {params?.InputProps.endAdornment || null} + + + ) : ( + params?.InputProps.endAdornment || null + ), }, inputProps: { ...FieldProps?.inputProps,