-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #296 from humanmade/add-term-search-control
Add TermSearchControl component
- Loading branch information
Showing
5 changed files
with
253 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
.DS_Store | ||
.history | ||
*.log | ||
/.idea/ | ||
/.vscode/ | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
# TermSearchControl | ||
|
||
The `TermSearchControl` component allows for live searching the terms in a taxonomy. It is ideal for use with taxonomies with a high number of terms, where returning all of them in a single query is not possible. It wraps a [`FormTokenField`](https://github.com/WordPress/gutenberg/tree/trunk/packages/components/src/form-token-field) component, and is based off of the taxonomy controls in the [Query Loop block](https://github.com/WordPress/gutenberg/blob/trunk/packages/block-library/src/query/edit/inspector-controls/taxonomy-controls.js). | ||
|
||
## Usage | ||
|
||
To use this component, you need to pass a `taxonomy` slug and an array of term IDs as `termIds` to `TermSearchControl`, as well as an `onChange` callback that accepts an array of term IDs. | ||
|
||
```js | ||
import { TermSearchControl } from '@humanmade/block-editor-components'; | ||
import { InspectorControls } from '@wordpress/block-editor'; | ||
import { PanelBody } from '@wordpress/components'; | ||
|
||
/** | ||
* Block edit view. | ||
* | ||
* @param {object} props - Component props. | ||
* @returns {ReactNode} Component. | ||
*/ | ||
const Edit = ( props ) => { | ||
const { attributes, setAttributes } = props; | ||
const { tags } = attributes; | ||
|
||
return ( | ||
<InspectorControls> | ||
<PanelBody> | ||
<TermSearchControl | ||
taxonomy="post_tag" | ||
termIds={ tags } | ||
onChange={ ( tags ) => | ||
setAttributes( { tags } ) | ||
} | ||
/> | ||
</PanelBody> | ||
</InspectorControls> | ||
); | ||
} | ||
``` | ||
|
||
Additionally, you can pass a `label` to override the default label. The default label displays in the format 'Filter by {taxonomy name}>'. | ||
|
||
```js | ||
<TermSearchControl | ||
label="Override label text" | ||
taxonomy="post_tag" | ||
termIds={ tags } | ||
onChange={ ( tags ) => | ||
setAttributes( { tags } ) | ||
} | ||
/> | ||
``` | ||
|
||
## Dependencies | ||
|
||
The `TermSearchControl` component requires the following dependencies, which are expected to be available: | ||
|
||
- `@wordpress/components` | ||
- `@wordpress/compose` | ||
- `@wordpress/core-data` | ||
- `@wordpress/data` | ||
- `@wordpress/element` | ||
- `@wordpress/html-entities` | ||
- `@wordpress/i18n` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,187 @@ | ||
import { FormTokenField } from '@wordpress/components'; | ||
import { useDebounce } from '@wordpress/compose'; | ||
import { store as coreStore } from '@wordpress/core-data'; | ||
import { useSelect } from '@wordpress/data'; | ||
import { useState, useEffect } from '@wordpress/element'; | ||
import { decodeEntities } from '@wordpress/html-entities'; | ||
import { __, sprintf } from '@wordpress/i18n'; | ||
|
||
const EMPTY_ARRAY = []; | ||
const BASE_QUERY = { | ||
order: 'asc', | ||
_fields: 'id,name', | ||
context: 'view', | ||
}; | ||
|
||
/** | ||
* Helper function to get the term id based on user input in terms `FormTokenField`. | ||
* | ||
* @param {Array} terms Array of terms from the search results. | ||
* @param {string|object} termValue Single term name or object. | ||
* | ||
* @returns {number} The term ID. | ||
*/ | ||
const getTermIdByTermValue = ( terms, termValue ) => { | ||
// First we check for exact match by `term.id` or case sensitive `term.name` match. | ||
const termId = | ||
termValue?.id || terms?.find( ( term ) => term.name === termValue )?.id; | ||
if ( termId ) { | ||
return termId; | ||
} | ||
|
||
/** | ||
* Here we make an extra check for entered terms in a non case sensitive way, | ||
* to match user expectations, due to `FormTokenField` behaviour that shows | ||
* suggestions which are case insensitive. | ||
* | ||
* Although WP tries to discourage users to add terms with the same name (case insensitive), | ||
* it's still possible if you manually change the name, as long as the terms have different slugs. | ||
* In this edge case we always apply the first match from the terms list. | ||
*/ | ||
const termValueLower = termValue.toLocaleLowerCase(); | ||
return terms?.find( ( term ) => term.name.toLocaleLowerCase() === termValueLower ) | ||
?.id; | ||
}; | ||
|
||
/** | ||
* Renders a `FormTokenField` for a given taxonomy. Based on the Query Loop block taxonomy controls. | ||
* https://github.com/WordPress/gutenberg/blob/trunk/packages/block-library/src/query/edit/inspector-controls/taxonomy-controls.js | ||
* | ||
* @param {object} props The props for the component. | ||
* @param {string} props.label The label text for the search field. | ||
* @param {object} props.taxonomy The taxonomy object. | ||
* @param {number[]} props.termIds An array with the block's term ids for the given taxonomy. | ||
* @param {Function} props.onChange Callback `onChange` function. | ||
* | ||
* @returns {Element} The rendered component. | ||
*/ | ||
function TermSearchControl( { label, taxonomy, termIds, onChange } ) { | ||
const [ search, setSearch ] = useState( '' ); | ||
const [ value, setValue ] = useState( EMPTY_ARRAY ); | ||
const [ suggestions, setSuggestions ] = useState( EMPTY_ARRAY ); | ||
const debouncedSearch = useDebounce( setSearch, 250 ); | ||
const taxObject = useSelect( | ||
( select ) => { | ||
return select( 'core' ).getTaxonomy( taxonomy ); | ||
}, | ||
[ taxonomy ] | ||
); | ||
const { searchResults, searchHasResolved } = useSelect( | ||
( select ) => { | ||
if ( ! search ) { | ||
return { | ||
searchResults: EMPTY_ARRAY, | ||
searchHasResolved: true, | ||
}; | ||
} | ||
const { getEntityRecords, hasFinishedResolution } = select( coreStore ); | ||
const selectorArgs = [ | ||
'taxonomy', | ||
taxonomy, | ||
{ | ||
...BASE_QUERY, | ||
search, | ||
orderby: 'name', | ||
exclude: termIds, | ||
per_page: 20, | ||
}, | ||
]; | ||
return { | ||
searchResults: getEntityRecords( ...selectorArgs ), | ||
searchHasResolved: hasFinishedResolution( | ||
'getEntityRecords', | ||
selectorArgs | ||
), | ||
}; | ||
}, | ||
[ search, termIds ] | ||
); | ||
|
||
// `existingTerms` are the ones fetched from the API and their type is `{ id: number; name: string }`. | ||
// They are used to extract the terms' names to populate the `FormTokenField` properly | ||
// and to sanitize the provided `termIds`, by setting only the ones that exist. | ||
const existingTerms = useSelect( | ||
( select ) => { | ||
if ( ! termIds?.length ) { | ||
return EMPTY_ARRAY; | ||
} | ||
const { getEntityRecords } = select( coreStore ); | ||
return getEntityRecords( 'taxonomy', taxonomy, { | ||
...BASE_QUERY, | ||
include: termIds, | ||
per_page: termIds.length, | ||
} ); | ||
}, | ||
[ termIds ] | ||
); | ||
|
||
// Update the `value` state only after the selectors are resolved | ||
// to avoid emptying the input when we're changing terms. | ||
useEffect( () => { | ||
if ( ! termIds?.length ) { | ||
setValue( EMPTY_ARRAY ); | ||
} | ||
if ( ! existingTerms?.length ) { | ||
return; | ||
} | ||
// Returns only the existing entity ids. This prevents the component | ||
// from crashing in the editor, when non existing ids are provided. | ||
const sanitizedValue = termIds.reduce( ( accumulator, id ) => { | ||
const entity = existingTerms.find( ( term ) => term.id === id ); | ||
if ( entity ) { | ||
accumulator.push( { | ||
id, | ||
value: entity.name, | ||
} ); | ||
} | ||
return accumulator; | ||
}, [] ); | ||
setValue( sanitizedValue ); | ||
}, [ termIds, existingTerms ] ); | ||
|
||
// Update suggestions only when the query has resolved. | ||
useEffect( () => { | ||
if ( ! searchHasResolved ) { | ||
return; | ||
} | ||
setSuggestions( searchResults.map( ( result ) => result.name ) ); | ||
}, [ searchResults, searchHasResolved ] ); | ||
|
||
/** | ||
* Function to handle change of selected terms. | ||
* | ||
* @param {Array} newTermValues Array of new term values. | ||
*/ | ||
const onTermsChange = ( newTermValues ) => { | ||
const newTermIds = new Set(); | ||
for ( const termValue of newTermValues ) { | ||
const termId = getTermIdByTermValue( searchResults, termValue ); | ||
if ( termId ) { | ||
newTermIds.add( termId ); | ||
} | ||
} | ||
setSuggestions( EMPTY_ARRAY ); | ||
onChange( Array.from( newTermIds ) ); | ||
}; | ||
|
||
return ( | ||
<FormTokenField | ||
displayTransform={ decodeEntities } | ||
label={ | ||
label || | ||
sprintf( | ||
__( 'Filter by %s', 'block-editor-components' ), | ||
taxObject | ||
? taxObject?.labels?.singular_name | ||
: __( 'term', 'block-editor-components' ) | ||
) | ||
} | ||
suggestions={ suggestions } | ||
value={ value } | ||
onChange={ onTermsChange } | ||
onInputChange={ debouncedSearch } | ||
/> | ||
); | ||
} | ||
|
||
export default TermSearchControl; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters