forked from WordPress/gutenberg
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add taxonomy controls component for searching and selecting terms
- Loading branch information
1 parent
f64a0af
commit e58e4e5
Showing
2 changed files
with
259 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 |
---|---|---|
@@ -0,0 +1,191 @@ | ||
/** | ||
* WordPress dependencies | ||
*/ | ||
import { FormTokenField } from '@wordpress/components'; | ||
import { useSelect } from '@wordpress/data'; | ||
import { store as coreStore } from '@wordpress/core-data'; | ||
import { useState, useEffect } from '@wordpress/element'; | ||
import { useDebounce } from '@wordpress/compose'; | ||
import { decodeEntities } from '@wordpress/html-entities'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import { useTaxonomies } from './utils'; | ||
|
||
const BASE_QUERY = { | ||
order: 'asc', | ||
_fields: 'id,name', | ||
context: 'view', | ||
}; | ||
const EMPTY_ARRAY = []; | ||
|
||
// Helper function to get the term id based on user input in terms `FormTokenField`. | ||
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; | ||
}; | ||
|
||
export function TaxonomyControls( { categories, onChange, postType } ) { | ||
const taxonomies = useTaxonomies( postType ); | ||
|
||
if ( ! taxonomies || taxonomies.length === 0 ) { | ||
return null; | ||
} | ||
|
||
return ( | ||
<> | ||
{ taxonomies.map( ( taxonomy ) => { | ||
const termIds = categories[ taxonomy.slug ] || []; | ||
const handleChange = ( newTermIds ) => | ||
onChange( { | ||
...categories, | ||
[ taxonomy.slug ]: newTermIds, | ||
} ); | ||
|
||
return ( | ||
<TaxonomyItem | ||
key={ taxonomy.slug } | ||
taxonomy={ taxonomy } | ||
termIds={ termIds } | ||
onChange={ handleChange } | ||
/> | ||
); | ||
} ) } | ||
</> | ||
); | ||
} | ||
|
||
/** | ||
* Renders a `FormTokenField` for a given taxonomy. | ||
* | ||
* @param {Object} props The props for the component. | ||
* @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. | ||
* @return {JSX.Element} The rendered component. | ||
*/ | ||
function TaxonomyItem( { 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 { searchResults, searchHasResolved } = useSelect( | ||
( select ) => { | ||
if ( ! search ) { | ||
return { | ||
searchResults: EMPTY_ARRAY, | ||
searchHasResolved: true, | ||
}; | ||
} | ||
const { getEntityRecords, hasFinishedResolution } = | ||
select( coreStore ); | ||
const selectorArgs = [ | ||
'taxonomy', | ||
taxonomy.slug, | ||
{ | ||
...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.slug, { | ||
...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 ] ); | ||
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 | ||
label={ taxonomy.name } | ||
value={ value } | ||
onInputChange={ debouncedSearch } | ||
suggestions={ suggestions } | ||
displayTransform={ decodeEntities } | ||
onChange={ onTermsChange } | ||
__experimentalShowHowTo={ false } | ||
/> | ||
); | ||
} |
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