Skip to content

Commit

Permalink
Merge pull request #296 from humanmade/add-term-search-control
Browse files Browse the repository at this point in the history
Add TermSearchControl component
  • Loading branch information
kadamwhite authored Feb 20, 2025
2 parents 642bb25 + 3da3f96 commit c2bbdd0
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.DS_Store
.history
*.log
/.idea/
/.vscode/
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ One way to ensure all dependencies are loaded is to use the [`@wordpress/depende
- [`PostTitleControl`](src/components/PostTitleControl)
- [`PostTypeCheck`](src/components/PostTypeCheck)
- [`RichTextWithLimit`](src/components/RichTextWithLimit)
- [`TermSearchControl`](src/components/TermSearchControl)

## Hooks

Expand Down
63 changes: 63 additions & 0 deletions src/components/TermSearchControl/README.md
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`
187 changes: 187 additions & 0 deletions src/components/TermSearchControl/index.js
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;
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export { default as PlainTextWithLimit } from './components/PlainTextWithLimit';
export { default as PostTitleControl } from './components/PostTitleControl';
export { default as PostTypeCheck } from './components/PostTypeCheck';
export { default as RichTextWithLimit } from './components/RichTextWithLimit';
export { default as TermSearchControl } from './components/TermSearchControl';
export { default as TermSelector } from './components/TermSelector';
export {
PostPickerButton,
Expand Down

0 comments on commit c2bbdd0

Please sign in to comment.