Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mui-autocomplete): add AsyncAutocomplete component #242

Merged
merged 1 commit into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion packages/autocomplete/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -50,12 +50,92 @@ 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 (
<Autocomplete
options={[
{ label: 'Option 1', value: 1 },
{ label: 'Option 2', value: 2 },
{ label: 'Option 3', value: 3 },
]}
getOptionLabel={(value) => value.label}
FieldProps={{ label: 'My Autocomplete Field', helperText: 'Text that helps the user' }}
/>
);
};
```

#### Direct import

```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 (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="dropdown"
render={({ field: { onChange, value, onBlur } }) => {
return (
<Autocomplete
onChange={(event, value, reason) => {
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}
/>
);
}}
/>
<Button type="submit">Submit</Button>
</form>
);
};
```

#### `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 <Autocomplete FieldProps={{ label: 'Async Dropdown' }} loadOptions={loadOptions} />;
};
```
1 change: 1 addition & 0 deletions packages/autocomplete/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './lib/Autocomplete';
export * from './lib/AsyncAutocomplete';
86 changes: 86 additions & 0 deletions packages/autocomplete/src/lib/AsyncAutocomplete.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AsyncAutocomplete
FieldProps={{ label: 'Test' }}
loadOptions={async () => ({
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(<AsyncAutocomplete loadOptions={loadOptions} FieldProps={{ label: 'Test' }} />);

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(<AsyncAutocomplete loadOptions={loadOptions} FieldProps={{ label: 'Test' }} />);

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);
});
});
});
74 changes: 74 additions & 0 deletions packages/autocomplete/src/lib/AsyncAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -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<AutocompleteProps<Option, Multiple, DisableClearable, FreeSolo, ChipComponent>, '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<Option, Multiple, DisableClearable, FreeSolo, ChipComponent>) => {
const [page, setPage] = useState(0);
const [options, setOptions] = useState<Option[]>([]);
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 (
<Autocomplete
{...rest}
loading={loading}
options={options}
ListboxProps={{
onScroll: async (event: React.SyntheticEvent) => {
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);
}
},
}}
/>
);
};
110 changes: 109 additions & 1 deletion packages/autocomplete/src/lib/Autocomplete.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Autocomplete> = {
title: 'Components/Autocomplete/Autocomplete',
Expand Down Expand Up @@ -42,3 +42,111 @@ export const _Multi: StoryObj<typeof Autocomplete> = {
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<typeof AsyncAutocomplete> = {
render: (args) => {
return <AsyncAutocomplete {...args} />;
},
args: {
FieldProps: { label: 'Async Select', helperText: 'Helper Text', fullWidth: false },
getOptionLabel: (val: Org) => val.name,
loadOptions,
limit: 10,
},
};
Loading
Loading