Skip to content

Commit

Permalink
Add taxonomy controls component for searching and selecting terms
Browse files Browse the repository at this point in the history
  • Loading branch information
amitraj2203 committed Jun 10, 2024
1 parent f64a0af commit e58e4e5
Show file tree
Hide file tree
Showing 2 changed files with 259 additions and 0 deletions.
191 changes: 191 additions & 0 deletions packages/block-library/src/search/taxonomy-controls.js
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 }
/>
);
}
68 changes: 68 additions & 0 deletions packages/block-library/src/search/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* WordPress dependencies
*/
import { useSelect } from '@wordpress/data';
import { useMemo } from '@wordpress/element';
import { store as coreStore } from '@wordpress/core-data';

/**
* Constants
*/
Expand All @@ -15,3 +22,64 @@ export const MIN_WIDTH = 220;
export function isPercentageUnit( unit ) {
return unit === '%';
}

/**
* Returns a helper object that contains:
* 1. An `options` object from the available post types, to be passed to a `SelectControl`.
* 2. A helper map with available taxonomies per post type.
*
* @return {Object} The helper object related to post types.
*/
export const usePostTypes = () => {
const postTypes = useSelect( ( select ) => {
const { getPostTypes } = select( coreStore );
const excludedPostTypes = [ 'attachment' ];
const filteredPostTypes = getPostTypes( { per_page: -1 } )?.filter(
( { viewable, slug } ) =>
viewable && ! excludedPostTypes.includes( slug )
);
return filteredPostTypes;
}, [] );
const postTypesTaxonomiesMap = useMemo( () => {
if ( ! postTypes?.length ) {
return;
}
return postTypes.reduce( ( accumulator, type ) => {
accumulator[ type.slug ] = type.taxonomies;
return accumulator;
}, {} );
}, [ postTypes ] );
const postTypesSelectOptions = useMemo(
() =>
( postTypes || [] ).map( ( { labels, slug } ) => ( {
label: labels.singular_name,
value: slug,
} ) ),
[ postTypes ]
);
return { postTypesTaxonomiesMap, postTypesSelectOptions };
};

/**
* Hook that returns the taxonomies associated with a specific post type.
*
* @param {string} postType The post type from which to retrieve the associated taxonomies.
* @return {Object[]} An array of the associated taxonomies.
*/
export const useTaxonomies = ( postType ) => {
const taxonomies = useSelect(
( select ) => {
const { getTaxonomies } = select( coreStore );
return getTaxonomies( {
type: postType,
per_page: -1,
} );
},
[ postType ]
);
return useMemo( () => {
return taxonomies?.filter(
( { visibility } ) => !! visibility?.publicly_queryable
);
}, [ taxonomies ] );
};

0 comments on commit e58e4e5

Please sign in to comment.