Add async filtering support to the select-control component (#35839)

* Add async filtering support to the select-control component

* Apply comment suggestions

* Apply more comment suggestions
This commit is contained in:
Maikel David Pérez Gómez 2022-12-07 14:24:32 -03:00 committed by GitHub
parent 0a039a1f13
commit 788b3ef59b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 430 additions and 48 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add async filtering support to the `__experimentalSelectControl` component

View File

@ -111,6 +111,7 @@
"@testing-library/dom": "^8.11.3",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.3",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.184",

View File

@ -0,0 +1,79 @@
/**
* External dependencies
*/
import { Spinner } from '@wordpress/components';
import { useDebounce } from '@wordpress/compose';
import { useCallback, useState, createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { SelectControlProps } from '../select-control';
import { SuffixIcon } from '../suffix-icon';
export const DEFAULT_DEBOUNCE_TIME = 250;
export default function useAsyncFilter< T >( {
filter,
onFilterStart,
onFilterEnd,
onFilterError,
debounceTime,
}: UseAsyncFilterInput< T > ): UseAsyncFilterOutput< T > {
const [ isFetching, setIsFetching ] = useState( false );
const handleInputChange = useCallback(
function handleInputChangeCallback( value?: string ) {
if ( typeof filter === 'function' ) {
if ( typeof onFilterStart === 'function' )
onFilterStart( value );
setIsFetching( true );
filter( value )
.then( ( filteredItems ) => {
if ( typeof onFilterEnd === 'function' )
onFilterEnd( filteredItems, value );
} )
.catch( ( error: Error ) => {
if ( typeof onFilterError === 'function' )
onFilterError( error, value );
} )
.finally( () => {
setIsFetching( false );
} );
}
},
[ filter, onFilterStart, onFilterEnd, onFilterError ]
);
return {
isFetching,
suffix:
isFetching === true ? (
<SuffixIcon icon={ <Spinner /> } />
) : undefined,
getFilteredItems: ( items ) => items,
onInputChange: useDebounce(
handleInputChange,
typeof debounceTime === 'number'
? debounceTime
: DEFAULT_DEBOUNCE_TIME
),
};
}
export type UseAsyncFilterInput< T > = {
filter( value?: string ): Promise< T[] >;
onFilterStart?( value?: string ): void;
onFilterEnd?( filteredItems: T[], value?: string ): void;
onFilterError?( error: Error, value?: string ): void;
debounceTime?: number;
};
export type UseAsyncFilterOutput< T > = Pick<
SelectControlProps< T >,
'suffix' | 'onInputChange' | 'getFilteredItems'
> & {
isFetching: boolean;
};

View File

@ -1 +1,2 @@
export * from './select-control';
export { default as useAsyncFilter } from './hooks/use-async-filter';

View File

@ -36,7 +36,7 @@ import {
defaultGetFilteredItems,
} from './utils';
type SelectControlProps< ItemType > = {
export type SelectControlProps< ItemType > = {
children?: ChildrenType< ItemType >;
items: ItemType[];
label: string | JSX.Element;

View File

@ -8,7 +8,7 @@ import {
SlotFillProvider,
Spinner,
} from '@wordpress/components';
import React from 'react';
import React, { useCallback } from 'react';
import { createElement, useState } from '@wordpress/element';
import { tag } from '@wordpress/icons';
@ -17,11 +17,15 @@ import { tag } from '@wordpress/icons';
*/
import { SelectedType, DefaultItemType, getItemLabelType } from '../types';
import { MenuItem } from '../menu-item';
import { SelectControl, selectControlStateChangeTypes } from '../';
import {
SelectControl,
selectControlStateChangeTypes,
useAsyncFilter,
} from '../';
import { Menu, MenuSlot } from '../menu';
import { SuffixIcon } from '../suffix-icon';
const sampleItems = [
const sampleItems: DefaultItemType[] = [
{ value: 'apple', label: 'Apple' },
{ value: 'pear', label: 'Pear' },
{ value: 'orange', label: 'Orange' },
@ -133,35 +137,121 @@ export const FuzzyMatching: React.FC = () => {
export const Async: React.FC = () => {
const [ selectedItem, setSelectedItem ] =
useState< SelectedType< DefaultItemType > >( null );
useState< DefaultItemType | null >( null );
const [ fetchedItems, setFetchedItems ] = useState< DefaultItemType[] >(
[]
);
const [ isFetching, setIsFetching ] = useState( false );
const fetchItems = ( value: string | undefined ) => {
setIsFetching( true );
setFetchedItems( [] );
setTimeout( () => {
const results = sampleItems.sort( () => 0.5 - Math.random() );
setFetchedItems( results );
setIsFetching( false );
}, 1500 );
};
const filter = useCallback(
( value = '' ) =>
new Promise< DefaultItemType[] >( ( resolve ) => {
setTimeout( () => {
const filteredItems = [ ...sampleItems ]
.sort( ( a, b ) => a.label.localeCompare( b.label ) )
.filter( ( { label } ) =>
label.toLowerCase().includes( value.toLowerCase() )
);
resolve( filteredItems );
}, 1500 );
} ),
[ selectedItem ]
);
const { isFetching, ...selectProps } = useAsyncFilter< DefaultItemType >( {
filter,
onFilterStart() {
setFetchedItems( [] );
},
onFilterEnd( filteredItems ) {
setFetchedItems( filteredItems );
},
} );
return (
<>
<SelectControl
<SelectControl< DefaultItemType >
{ ...selectProps }
label="Async"
getFilteredItems={ ( allItems ) => {
return allItems;
} }
items={ fetchedItems }
onInputChange={ fetchItems }
selected={ selectedItem }
onSelect={ ( item ) => setSelectedItem( item ) }
onRemove={ () => setSelectedItem( null ) }
placeholder="Start typing..."
onSelect={ setSelectedItem }
onRemove={ () => setSelectedItem( null ) }
>
{ ( {
items,
isOpen,
highlightedIndex,
getItemProps,
getMenuProps,
} ) => {
return (
<Menu isOpen={ isOpen } getMenuProps={ getMenuProps }>
{ isFetching ? (
<Spinner />
) : (
items.map( ( item, index: number ) => (
<MenuItem
key={ `${ item.value }${ index }` }
index={ index }
isActive={ highlightedIndex === index }
item={ item }
getItemProps={ getItemProps }
>
{ item.label }
</MenuItem>
) )
) }
</Menu>
);
} }
</SelectControl>
</>
);
};
export const AsyncWithoutListeningFilterEvents: React.FC = () => {
const [ selectedItem, setSelectedItem ] =
useState< DefaultItemType | null >( null );
const [ fetchedItems, setFetchedItems ] = useState< DefaultItemType[] >(
[]
);
const filter = useCallback(
async ( value = '' ) => {
setFetchedItems( [] );
return new Promise< DefaultItemType[] >( ( resolve ) => {
setTimeout( () => {
const filteredItems = [ ...sampleItems ]
.sort( ( a, b ) => a.label.localeCompare( b.label ) )
.filter( ( { label } ) =>
label.toLowerCase().includes( value.toLowerCase() )
);
resolve( filteredItems );
}, 1500 );
} ).then( ( filteredItems ) => {
setFetchedItems( filteredItems );
return filteredItems;
} );
},
[ selectedItem ]
);
const { isFetching, ...selectProps } = useAsyncFilter< DefaultItemType >( {
filter,
} );
return (
<>
<SelectControl< DefaultItemType >
{ ...selectProps }
label="Async"
items={ fetchedItems }
selected={ selectedItem }
placeholder="Start typing..."
onSelect={ setSelectedItem }
onRemove={ () => setSelectedItem( null ) }
>
{ ( {
items,

View File

@ -3,4 +3,8 @@
align-items: center;
height: 100%;
padding-right: $gap-smaller;
.components-spinner {
margin: 0;
}
}

View File

@ -0,0 +1,128 @@
/**
* External dependencies
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useDebounce } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { useAsyncFilter } from '../';
jest.mock( '@wordpress/compose', () => ( {
...jest.requireActual( '@wordpress/compose' ),
useDebounce: jest.fn( ( cb: CallableFunction ) => cb ),
} ) );
describe( 'useAsyncFilter', () => {
const filter = jest.fn();
const onFilterStart = jest.fn();
const onFilterEnd = jest.fn();
const onFilterError = jest.fn();
afterEach( () => {
jest.clearAllMocks();
} );
it( 'should filter the items successfully', async () => {
const filteredItems: string[] = [];
filter.mockResolvedValue( filteredItems );
const { result } = renderHook( () =>
useAsyncFilter( {
filter,
} )
);
const inputValue = 'Apple';
await act( async () => {
if ( result.current.onInputChange )
result.current.onInputChange( inputValue, {} );
} );
expect( useDebounce ).toHaveBeenCalledWith(
expect.any( Function ),
250
);
expect( filter ).toHaveBeenCalledWith( inputValue );
} );
it( 'should trigger onFilterStart at the begining of the filtering', async () => {
const filteredItems: string[] = [];
onFilterStart.mockImplementation( ( value = '' ) => {
expect( filter ).not.toHaveBeenCalledWith( value );
} );
filter.mockImplementation( ( value = '' ) => {
expect( onFilterStart ).toHaveBeenCalledWith( value );
return Promise.resolve( filteredItems );
} );
const { result } = renderHook( () =>
useAsyncFilter( {
filter,
onFilterStart,
} )
);
const inputValue = 'Apple';
await act( async () => {
if ( result.current.onInputChange )
result.current.onInputChange( inputValue, {} );
} );
expect( filter ).toHaveBeenCalledWith( inputValue );
} );
it( 'should trigger onFilterEnd when filtering is fullfiled', async () => {
const filteredItems: string[] = [];
filter.mockResolvedValue( filteredItems );
const { result } = renderHook( () =>
useAsyncFilter( {
filter,
onFilterEnd,
onFilterError,
} )
);
const inputValue = 'Apple';
await act( async () => {
if ( result.current.onInputChange )
result.current.onInputChange( inputValue, {} );
} );
expect( onFilterEnd ).toHaveBeenCalledWith( filteredItems, inputValue );
expect( onFilterError ).not.toHaveBeenCalled();
} );
it( 'should trigger onFilterError when filtering is rejected', async () => {
const error = new Error();
filter.mockRejectedValue( error );
const { result } = renderHook( () =>
useAsyncFilter( {
filter,
onFilterEnd,
onFilterError,
} )
);
const inputValue = 'Apple';
await act( async () => {
if ( result.current.onInputChange )
result.current.onInputChange( inputValue, {} );
} );
expect( onFilterEnd ).not.toHaveBeenCalled();
expect( onFilterError ).toHaveBeenCalledWith( error, inputValue );
} );
} );

View File

@ -45,6 +45,7 @@ export { default as SelectControl } from './select-control';
export {
SelectControl as __experimentalSelectControl,
selectControlStateChangeTypes,
useAsyncFilter,
} from './experimental-select-control';
export {
MenuItem as __experimentalSelectControlMenuItem,

View File

@ -200,6 +200,7 @@ importers:
'@testing-library/dom': ^8.11.3
'@testing-library/jest-dom': ^5.16.2
'@testing-library/react': ^12.1.3
'@testing-library/react-hooks': ^8.0.1
'@testing-library/user-event': ^13.5.0
'@types/jest': ^27.4.1
'@types/lodash': ^4.14.184
@ -355,6 +356,7 @@ importers:
'@testing-library/dom': 8.11.3
'@testing-library/jest-dom': 5.16.2
'@testing-library/react': 12.1.4_sfoxds7t5ydpegc3knd667wn6m
'@testing-library/react-hooks': 8.0.1_hiunvzosbwliizyirxfy6hjyim
'@testing-library/user-event': 13.5.0_gzufz4q333be4gqfrvipwvqt6a
'@types/jest': 27.4.1
'@types/lodash': 4.14.184
@ -2932,6 +2934,42 @@ packages:
- supports-color
dev: true
/@babel/helper-define-polyfill-provider/0.3.0_@babel+core@7.16.12:
resolution: {integrity: sha512-7hfT8lUljl/tM3h+izTX/pO3W3frz2ok6Pk+gzys8iJqDfZrZy2pXjRTZAvG2YmfHun1X4q8/UZRLatMfqc5Tg==}
peerDependencies:
'@babel/core': ^7.4.0-0
dependencies:
'@babel/core': 7.16.12
'@babel/helper-compilation-targets': 7.19.3_@babel+core@7.16.12
'@babel/helper-module-imports': 7.18.6
'@babel/helper-plugin-utils': 7.19.0
'@babel/traverse': 7.19.3
debug: 4.3.4
lodash.debounce: 4.0.8
resolve: 1.22.1
semver: 6.3.0
transitivePeerDependencies:
- supports-color
dev: false
/@babel/helper-define-polyfill-provider/0.3.0_@babel+core@7.17.8:
resolution: {integrity: sha512-7hfT8lUljl/tM3h+izTX/pO3W3frz2ok6Pk+gzys8iJqDfZrZy2pXjRTZAvG2YmfHun1X4q8/UZRLatMfqc5Tg==}
peerDependencies:
'@babel/core': ^7.4.0-0
dependencies:
'@babel/core': 7.17.8
'@babel/helper-compilation-targets': 7.19.3_@babel+core@7.17.8
'@babel/helper-module-imports': 7.18.6
'@babel/helper-plugin-utils': 7.19.0
'@babel/traverse': 7.19.3
debug: 4.3.4
lodash.debounce: 4.0.8
resolve: 1.22.1
semver: 6.3.0
transitivePeerDependencies:
- supports-color
dev: true
/@babel/helper-define-polyfill-provider/0.3.1_@babel+core@7.12.9:
resolution: {integrity: sha512-J9hGMpJQmtWmj46B3kBHmL38UhJGhYX7eqkcq+2gsstyYt341HmPeWspihX43yVRA0mS+8GGk2Gckc7bY/HCmA==}
peerDependencies:
@ -3576,8 +3614,8 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.12.9
'@babel/helper-create-class-features-plugin': 7.17.6_@babel+core@7.12.9
'@babel/helper-plugin-utils': 7.18.9
'@babel/helper-create-class-features-plugin': 7.19.0_@babel+core@7.12.9
'@babel/helper-plugin-utils': 7.19.0
transitivePeerDependencies:
- supports-color
dev: true
@ -3589,8 +3627,8 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.16.12
'@babel/helper-create-class-features-plugin': 7.17.6_@babel+core@7.16.12
'@babel/helper-plugin-utils': 7.18.9
'@babel/helper-create-class-features-plugin': 7.19.0_@babel+core@7.16.12
'@babel/helper-plugin-utils': 7.19.0
transitivePeerDependencies:
- supports-color
dev: false
@ -6540,9 +6578,9 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.16.12
'@babel/helper-module-imports': 7.16.0
'@babel/helper-plugin-utils': 7.14.5
babel-plugin-polyfill-corejs2: 0.3.0_@babel+core@7.16.12
'@babel/helper-module-imports': 7.18.6
'@babel/helper-plugin-utils': 7.19.0
babel-plugin-polyfill-corejs2: 0.3.3_@babel+core@7.16.12
babel-plugin-polyfill-corejs3: 0.4.0_@babel+core@7.16.12
babel-plugin-polyfill-regenerator: 0.3.0_@babel+core@7.16.12
semver: 6.3.0
@ -6557,9 +6595,9 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.17.8
'@babel/helper-module-imports': 7.16.0
'@babel/helper-plugin-utils': 7.14.5
babel-plugin-polyfill-corejs2: 0.3.0_@babel+core@7.17.8
'@babel/helper-module-imports': 7.18.6
'@babel/helper-plugin-utils': 7.19.0
babel-plugin-polyfill-corejs2: 0.3.3_@babel+core@7.17.8
babel-plugin-polyfill-corejs3: 0.4.0_@babel+core@7.17.8
babel-plugin-polyfill-regenerator: 0.3.0_@babel+core@7.17.8
semver: 6.3.0
@ -6650,8 +6688,8 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.12.9
'@babel/helper-plugin-utils': 7.18.9
'@babel/helper-skip-transparent-expression-wrappers': 7.16.0
'@babel/helper-plugin-utils': 7.19.0
'@babel/helper-skip-transparent-expression-wrappers': 7.18.9
dev: true
/@babel/plugin-transform-spread/7.16.7_@babel+core@7.16.12:
@ -6661,8 +6699,8 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.16.12
'@babel/helper-plugin-utils': 7.18.9
'@babel/helper-skip-transparent-expression-wrappers': 7.16.0
'@babel/helper-plugin-utils': 7.19.0
'@babel/helper-skip-transparent-expression-wrappers': 7.18.9
dev: false
/@babel/plugin-transform-spread/7.16.7_@babel+core@7.17.8:
@ -6840,7 +6878,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.16.12
'@babel/helper-create-class-features-plugin': 7.17.6_@babel+core@7.16.12
'@babel/helper-create-class-features-plugin': 7.19.0_@babel+core@7.16.12
'@babel/helper-plugin-utils': 7.19.0
'@babel/plugin-syntax-typescript': 7.16.7_@babel+core@7.16.12
transitivePeerDependencies:
@ -7471,8 +7509,8 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.16.12
'@babel/helper-plugin-utils': 7.18.9
'@babel/helper-validator-option': 7.16.7
'@babel/helper-plugin-utils': 7.19.0
'@babel/helper-validator-option': 7.18.6
'@babel/plugin-transform-typescript': 7.16.8_@babel+core@7.16.12
transitivePeerDependencies:
- supports-color
@ -13300,6 +13338,29 @@ packages:
react-error-boundary: 3.1.4_react@17.0.2
dev: true
/@testing-library/react-hooks/8.0.1_hiunvzosbwliizyirxfy6hjyim:
resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==}
engines: {node: '>=12'}
peerDependencies:
'@types/react': ^16.9.0 || ^17.0.0
react: ^16.9.0 || ^17.0.0
react-dom: ^16.9.0 || ^17.0.0
react-test-renderer: ^16.9.0 || ^17.0.0
peerDependenciesMeta:
'@types/react':
optional: true
react-dom:
optional: true
react-test-renderer:
optional: true
dependencies:
'@babel/runtime': 7.19.0
'@types/react': 17.0.50
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
react-error-boundary: 3.1.4_react@17.0.2
dev: true
/@testing-library/react/12.1.4_sfoxds7t5ydpegc3knd667wn6m:
resolution: {integrity: sha512-jiPKOm7vyUw311Hn/HlNQ9P8/lHNtArAx0PisXyFixDDvfl8DbD6EUdbshK5eqauvBSvzZd19itqQ9j3nferJA==}
engines: {node: '>=12'}
@ -19127,6 +19188,19 @@ packages:
- supports-color
dev: true
/babel-plugin-polyfill-corejs2/0.3.3_@babel+core@7.16.12:
resolution: {integrity: sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/compat-data': 7.19.3
'@babel/core': 7.16.12
'@babel/helper-define-polyfill-provider': 0.3.3_@babel+core@7.16.12
semver: 6.3.0
transitivePeerDependencies:
- supports-color
dev: false
/babel-plugin-polyfill-corejs2/0.3.3_@babel+core@7.17.8:
resolution: {integrity: sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==}
peerDependencies:
@ -19169,7 +19243,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.16.12
'@babel/helper-define-polyfill-provider': 0.3.3_@babel+core@7.16.12
'@babel/helper-define-polyfill-provider': 0.3.0_@babel+core@7.16.12
core-js-compat: 3.25.5
transitivePeerDependencies:
- supports-color
@ -19181,7 +19255,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.17.8
'@babel/helper-define-polyfill-provider': 0.3.3_@babel+core@7.17.8
'@babel/helper-define-polyfill-provider': 0.3.0_@babel+core@7.17.8
core-js-compat: 3.25.5
transitivePeerDependencies:
- supports-color
@ -24004,7 +24078,7 @@ packages:
file-entry-cache: 6.0.1
functional-red-black-tree: 1.0.1
glob-parent: 5.1.2
globals: 13.17.0
globals: 13.18.0
ignore: 4.0.6
import-fresh: 3.3.0
imurmurhash: 0.1.4
@ -25327,7 +25401,7 @@ packages:
vue-template-compiler:
optional: true
dependencies:
'@babel/code-frame': 7.16.7
'@babel/code-frame': 7.18.6
'@types/json-schema': 7.0.9
chalk: 4.1.2
chokidar: 3.5.3
@ -25338,7 +25412,7 @@ packages:
memfs: 3.3.0
minimatch: 3.1.2
schema-utils: 2.7.0
semver: 7.3.5
semver: 7.3.8
tapable: 1.1.3
typescript: 4.8.4
webpack: 5.70.0
@ -25358,7 +25432,7 @@ packages:
vue-template-compiler:
optional: true
dependencies:
'@babel/code-frame': 7.16.7
'@babel/code-frame': 7.18.6
'@types/json-schema': 7.0.9
chalk: 4.1.2
chokidar: 3.5.3
@ -25370,7 +25444,7 @@ packages:
memfs: 3.3.0
minimatch: 3.1.2
schema-utils: 2.7.0
semver: 7.3.5
semver: 7.3.8
tapable: 1.1.3
typescript: 4.8.4
webpack: 4.46.0_webpack-cli@3.3.12
@ -25422,7 +25496,7 @@ packages:
vue-template-compiler:
optional: true
dependencies:
'@babel/code-frame': 7.16.7
'@babel/code-frame': 7.18.6
'@types/json-schema': 7.0.9
chalk: 4.1.2
chokidar: 3.5.3
@ -25433,7 +25507,7 @@ packages:
memfs: 3.3.0
minimatch: 3.1.2
schema-utils: 2.7.0
semver: 7.3.5
semver: 7.3.8
tapable: 1.1.3
typescript: 4.8.4
webpack: 4.46.0