Skip to content

Commit c2bbdd0

Browse files
authored
Merge pull request #296 from humanmade/add-term-search-control
Add TermSearchControl component
2 parents 642bb25 + 3da3f96 commit c2bbdd0

5 files changed

Lines changed: 253 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.DS_Store
2+
.history
23
*.log
34
/.idea/
45
/.vscode/

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ One way to ensure all dependencies are loaded is to use the [`@wordpress/depende
5454
- [`PostTitleControl`](src/components/PostTitleControl)
5555
- [`PostTypeCheck`](src/components/PostTypeCheck)
5656
- [`RichTextWithLimit`](src/components/RichTextWithLimit)
57+
- [`TermSearchControl`](src/components/TermSearchControl)
5758

5859
## Hooks
5960

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# TermSearchControl
2+
3+
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).
4+
5+
## Usage
6+
7+
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.
8+
9+
```js
10+
import { TermSearchControl } from '@humanmade/block-editor-components';
11+
import { InspectorControls } from '@wordpress/block-editor';
12+
import { PanelBody } from '@wordpress/components';
13+
14+
/**
15+
* Block edit view.
16+
*
17+
* @param {object} props - Component props.
18+
* @returns {ReactNode} Component.
19+
*/
20+
const Edit = ( props ) => {
21+
const { attributes, setAttributes } = props;
22+
const { tags } = attributes;
23+
24+
return (
25+
<InspectorControls>
26+
<PanelBody>
27+
<TermSearchControl
28+
taxonomy="post_tag"
29+
termIds={ tags }
30+
onChange={ ( tags ) =>
31+
setAttributes( { tags } )
32+
}
33+
/>
34+
</PanelBody>
35+
</InspectorControls>
36+
);
37+
}
38+
```
39+
40+
Additionally, you can pass a `label` to override the default label. The default label displays in the format 'Filter by {taxonomy name}>'.
41+
42+
```js
43+
<TermSearchControl
44+
label="Override label text"
45+
taxonomy="post_tag"
46+
termIds={ tags }
47+
onChange={ ( tags ) =>
48+
setAttributes( { tags } )
49+
}
50+
/>
51+
```
52+
53+
## Dependencies
54+
55+
The `TermSearchControl` component requires the following dependencies, which are expected to be available:
56+
57+
- `@wordpress/components`
58+
- `@wordpress/compose`
59+
- `@wordpress/core-data`
60+
- `@wordpress/data`
61+
- `@wordpress/element`
62+
- `@wordpress/html-entities`
63+
- `@wordpress/i18n`
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { FormTokenField } from '@wordpress/components';
2+
import { useDebounce } from '@wordpress/compose';
3+
import { store as coreStore } from '@wordpress/core-data';
4+
import { useSelect } from '@wordpress/data';
5+
import { useState, useEffect } from '@wordpress/element';
6+
import { decodeEntities } from '@wordpress/html-entities';
7+
import { __, sprintf } from '@wordpress/i18n';
8+
9+
const EMPTY_ARRAY = [];
10+
const BASE_QUERY = {
11+
order: 'asc',
12+
_fields: 'id,name',
13+
context: 'view',
14+
};
15+
16+
/**
17+
* Helper function to get the term id based on user input in terms `FormTokenField`.
18+
*
19+
* @param {Array} terms Array of terms from the search results.
20+
* @param {string|object} termValue Single term name or object.
21+
*
22+
* @returns {number} The term ID.
23+
*/
24+
const getTermIdByTermValue = ( terms, termValue ) => {
25+
// First we check for exact match by `term.id` or case sensitive `term.name` match.
26+
const termId =
27+
termValue?.id || terms?.find( ( term ) => term.name === termValue )?.id;
28+
if ( termId ) {
29+
return termId;
30+
}
31+
32+
/**
33+
* Here we make an extra check for entered terms in a non case sensitive way,
34+
* to match user expectations, due to `FormTokenField` behaviour that shows
35+
* suggestions which are case insensitive.
36+
*
37+
* Although WP tries to discourage users to add terms with the same name (case insensitive),
38+
* it's still possible if you manually change the name, as long as the terms have different slugs.
39+
* In this edge case we always apply the first match from the terms list.
40+
*/
41+
const termValueLower = termValue.toLocaleLowerCase();
42+
return terms?.find( ( term ) => term.name.toLocaleLowerCase() === termValueLower )
43+
?.id;
44+
};
45+
46+
/**
47+
* Renders a `FormTokenField` for a given taxonomy. Based on the Query Loop block taxonomy controls.
48+
* https://github.com/WordPress/gutenberg/blob/trunk/packages/block-library/src/query/edit/inspector-controls/taxonomy-controls.js
49+
*
50+
* @param {object} props The props for the component.
51+
* @param {string} props.label The label text for the search field.
52+
* @param {object} props.taxonomy The taxonomy object.
53+
* @param {number[]} props.termIds An array with the block's term ids for the given taxonomy.
54+
* @param {Function} props.onChange Callback `onChange` function.
55+
*
56+
* @returns {Element} The rendered component.
57+
*/
58+
function TermSearchControl( { label, taxonomy, termIds, onChange } ) {
59+
const [ search, setSearch ] = useState( '' );
60+
const [ value, setValue ] = useState( EMPTY_ARRAY );
61+
const [ suggestions, setSuggestions ] = useState( EMPTY_ARRAY );
62+
const debouncedSearch = useDebounce( setSearch, 250 );
63+
const taxObject = useSelect(
64+
( select ) => {
65+
return select( 'core' ).getTaxonomy( taxonomy );
66+
},
67+
[ taxonomy ]
68+
);
69+
const { searchResults, searchHasResolved } = useSelect(
70+
( select ) => {
71+
if ( ! search ) {
72+
return {
73+
searchResults: EMPTY_ARRAY,
74+
searchHasResolved: true,
75+
};
76+
}
77+
const { getEntityRecords, hasFinishedResolution } = select( coreStore );
78+
const selectorArgs = [
79+
'taxonomy',
80+
taxonomy,
81+
{
82+
...BASE_QUERY,
83+
search,
84+
orderby: 'name',
85+
exclude: termIds,
86+
per_page: 20,
87+
},
88+
];
89+
return {
90+
searchResults: getEntityRecords( ...selectorArgs ),
91+
searchHasResolved: hasFinishedResolution(
92+
'getEntityRecords',
93+
selectorArgs
94+
),
95+
};
96+
},
97+
[ search, termIds ]
98+
);
99+
100+
// `existingTerms` are the ones fetched from the API and their type is `{ id: number; name: string }`.
101+
// They are used to extract the terms' names to populate the `FormTokenField` properly
102+
// and to sanitize the provided `termIds`, by setting only the ones that exist.
103+
const existingTerms = useSelect(
104+
( select ) => {
105+
if ( ! termIds?.length ) {
106+
return EMPTY_ARRAY;
107+
}
108+
const { getEntityRecords } = select( coreStore );
109+
return getEntityRecords( 'taxonomy', taxonomy, {
110+
...BASE_QUERY,
111+
include: termIds,
112+
per_page: termIds.length,
113+
} );
114+
},
115+
[ termIds ]
116+
);
117+
118+
// Update the `value` state only after the selectors are resolved
119+
// to avoid emptying the input when we're changing terms.
120+
useEffect( () => {
121+
if ( ! termIds?.length ) {
122+
setValue( EMPTY_ARRAY );
123+
}
124+
if ( ! existingTerms?.length ) {
125+
return;
126+
}
127+
// Returns only the existing entity ids. This prevents the component
128+
// from crashing in the editor, when non existing ids are provided.
129+
const sanitizedValue = termIds.reduce( ( accumulator, id ) => {
130+
const entity = existingTerms.find( ( term ) => term.id === id );
131+
if ( entity ) {
132+
accumulator.push( {
133+
id,
134+
value: entity.name,
135+
} );
136+
}
137+
return accumulator;
138+
}, [] );
139+
setValue( sanitizedValue );
140+
}, [ termIds, existingTerms ] );
141+
142+
// Update suggestions only when the query has resolved.
143+
useEffect( () => {
144+
if ( ! searchHasResolved ) {
145+
return;
146+
}
147+
setSuggestions( searchResults.map( ( result ) => result.name ) );
148+
}, [ searchResults, searchHasResolved ] );
149+
150+
/**
151+
* Function to handle change of selected terms.
152+
*
153+
* @param {Array} newTermValues Array of new term values.
154+
*/
155+
const onTermsChange = ( newTermValues ) => {
156+
const newTermIds = new Set();
157+
for ( const termValue of newTermValues ) {
158+
const termId = getTermIdByTermValue( searchResults, termValue );
159+
if ( termId ) {
160+
newTermIds.add( termId );
161+
}
162+
}
163+
setSuggestions( EMPTY_ARRAY );
164+
onChange( Array.from( newTermIds ) );
165+
};
166+
167+
return (
168+
<FormTokenField
169+
displayTransform={ decodeEntities }
170+
label={
171+
label ||
172+
sprintf(
173+
__( 'Filter by %s', 'block-editor-components' ),
174+
taxObject
175+
? taxObject?.labels?.singular_name
176+
: __( 'term', 'block-editor-components' )
177+
)
178+
}
179+
suggestions={ suggestions }
180+
value={ value }
181+
onChange={ onTermsChange }
182+
onInputChange={ debouncedSearch }
183+
/>
184+
);
185+
}
186+
187+
export default TermSearchControl;

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export { default as PlainTextWithLimit } from './components/PlainTextWithLimit';
1212
export { default as PostTitleControl } from './components/PostTitleControl';
1313
export { default as PostTypeCheck } from './components/PostTypeCheck';
1414
export { default as RichTextWithLimit } from './components/RichTextWithLimit';
15+
export { default as TermSearchControl } from './components/TermSearchControl';
1516
export { default as TermSelector } from './components/TermSelector';
1617
export {
1718
PostPickerButton,

0 commit comments

Comments
 (0)