UNPKG

@wordpress/editor

Version:
511 lines (472 loc) 13.1 kB
import { __, _n, _x, sprintf } from '@wordpress/i18n'; import { memo, useDeferredValue, useEffect, useMemo, useState, } from '@wordpress/element'; import { store as noticesStore } from '@wordpress/notices'; import { Button, CheckboxControl, TreeSelect, withFilters, SearchControl, } from '@wordpress/components'; import { InputControl, Spinner, Stack } from '@wordpress/ui'; import { useDispatch, useSelect } from '@wordpress/data'; import { useDebounce, useEvent } from '@wordpress/compose'; import { store as coreStore, privateApis as coreDataPrivateApis, } from '@wordpress/core-data'; import { speak } from '@wordpress/a11y'; import { decodeEntities } from '@wordpress/html-entities'; import { buildTermsTree } from '../../utils/terms'; import { normalizeTextString } from '../../utils/normalize-text-string'; import { store as editorStore } from '../../store'; import { unlock } from '../../lock-unlock'; const { RECEIVE_INTERMEDIATE_RESULTS } = unlock( coreDataPrivateApis ); /** * Module Constants */ const DEFAULT_QUERY = { per_page: -1, orderby: 'name', order: 'asc', _fields: 'id,name,parent', context: 'view', [ RECEIVE_INTERMEDIATE_RESULTS ]: true, }; const MIN_TERMS_COUNT_FOR_FILTER = 8; const EMPTY_ARRAY = []; const SPEAK_DEBOUNCE_MS = 500; function getResultCount( termsTree ) { let count = 0; for ( const term of termsTree ) { count++; if ( undefined !== term.children ) { count += getResultCount( term.children ); } } return count; } // Memoized on primitive props, so toggling one term does not re-render every // checkbox in the list. const TermCheckbox = memo( function Checkbox( { id, name, checked, onToggle, } ) { return ( <CheckboxControl checked={ checked } onChange={ () => onToggle( id ) } label={ decodeEntities( name ) } /> ); } ); function TermRow( { term, selectedTerms, onToggle } ) { return ( <div className="editor-post-taxonomies__hierarchical-terms-choice"> <TermCheckbox id={ term.id } name={ term.name } checked={ selectedTerms.has( term.id ) } onToggle={ onToggle } /> { !! term.children.length && ( <div className="editor-post-taxonomies__hierarchical-terms-subchoices"> { term.children.map( ( child ) => ( <TermRow key={ child.id } term={ child } selectedTerms={ selectedTerms } onToggle={ onToggle } /> ) ) } </div> ) } </div> ); } /** * Sort Terms by Selected. * * @param {Object[]} termsTree Array of terms in tree format. * @param {number[]} terms Selected terms. * * @return {Object[]} Sorted array of terms. */ export function sortBySelected( termsTree, terms ) { const selectedTerms = new Set( terms ); const treeHasSelection = ( termTree ) => { if ( selectedTerms.has( termTree.id ) ) { return true; } return !! termTree.children?.some( treeHasSelection ); }; // Partition rather than sort: each subtree is walked once, and terms keep // their relative order within each group. const selected = []; const unselected = []; for ( const termTree of termsTree ) { if ( treeHasSelection( termTree ) ) { selected.push( termTree ); } else { unselected.push( termTree ); } } return [ ...selected, ...unselected ]; } /** * Find term by parent id or name. * * @param {Object[]} terms Array of Terms. * @param {number|string} parent id. * @param {string} name Term name. * @return {Object} Term object. */ export function findTerm( terms, parent, name ) { return terms.find( ( term ) => { return ( ( ( ! term.parent && ! parent ) || parseInt( term.parent ) === parseInt( parent ) ) && term.name.toLowerCase() === name.toLowerCase() ); } ); } /** * Get filter matcher function. * * @param {string} filterValue Filter value. * @return {(function(Object): (Object|boolean))} Matcher function. */ export function getFilterMatcher( filterValue ) { // Normalize once rather than per visited term. const normalizedFilterValue = normalizeTextString( filterValue ); const matchTermsForFilter = ( originalTerm ) => { if ( '' === filterValue ) { return originalTerm; } // Shallow clone, because we'll be filtering the term's children and // don't want to modify the original term. const term = { ...originalTerm }; // Map and filter the children, recursive so we deal with grandchildren // and any deeper levels. if ( term.children.length > 0 ) { term.children = term.children .map( matchTermsForFilter ) .filter( ( child ) => child ); } // If the term's name contains the filterValue, or it has children // (i.e. some child matched at some point in the tree) then return it. if ( -1 !== normalizeTextString( term.name ).indexOf( normalizedFilterValue ) || term.children.length > 0 ) { return term; } // Otherwise, return false. After mapping, the list of terms will need // to have false values filtered out. return false; }; return matchTermsForFilter; } /** * Hierarchical term selector. * * @param {Object} props Component props. * @param {string} props.slug Taxonomy slug. * @return {Element} Hierarchical term selector component. */ export function HierarchicalTermSelector( { slug } ) { const [ adding, setAdding ] = useState( false ); const [ formName, setFormName ] = useState( '' ); /** * @type {[number|'', Function]} */ const [ formParent, setFormParent ] = useState( '' ); const [ showForm, setShowForm ] = useState( false ); const [ filterValue, setFilterValue ] = useState( '' ); // Filtering rebuilds the visible list, so let typing stay responsive and // render the results in a lower priority pass. const deferredFilterValue = useDeferredValue( filterValue ); const debouncedSpeak = useDebounce( speak, SPEAK_DEBOUNCE_MS ); const { hasCreateAction, hasAssignAction, terms, loading, availableTerms, taxonomy, } = useSelect( ( select ) => { const { getCurrentPost, getEditedPostAttribute } = select( editorStore ); const { getEntityRecord, getEntityRecords, isResolving } = select( coreStore ); const _taxonomy = getEntityRecord( 'root', 'taxonomy', slug ); const post = getCurrentPost(); return { hasCreateAction: _taxonomy ? !! post._links?.[ 'wp:action-create-' + _taxonomy.rest_base ] : false, hasAssignAction: _taxonomy ? !! post._links?.[ 'wp:action-assign-' + _taxonomy.rest_base ] : false, terms: _taxonomy ? getEditedPostAttribute( _taxonomy.rest_base ) : EMPTY_ARRAY, loading: isResolving( 'getEntityRecords', [ 'taxonomy', slug, DEFAULT_QUERY, ] ), availableTerms: getEntityRecords( 'taxonomy', slug, DEFAULT_QUERY ) || EMPTY_ARRAY, taxonomy: _taxonomy, }; }, [ slug ] ); const { editPost } = useDispatch( editorStore ); const { saveEntityRecord } = useDispatch( coreStore ); const { createErrorNotice } = useDispatch( noticesStore ); const selectedTerms = useMemo( () => new Set( terms ), [ terms ] ); const availableTermsTree = useMemo( () => sortBySelected( buildTermsTree( availableTerms ), terms ), // Remove `terms` from the dependency list to avoid reordering every time // checking or unchecking a term. [ availableTerms ] ); const shownTerms = useMemo( () => { if ( '' === deferredFilterValue ) { return availableTermsTree; } return availableTermsTree .map( getFilterMatcher( deferredFilterValue ) ) .filter( ( term ) => term ); }, [ availableTermsTree, deferredFilterValue ] ); const resultCount = getResultCount( shownTerms ); useEffect( () => { if ( '' === deferredFilterValue ) { return; } debouncedSpeak( sprintf( /* translators: %d: number of results. */ _n( '%d result found.', '%d results found.', resultCount ), resultCount ), 'polite' ); return () => debouncedSpeak.cancel(); }, [ resultCount, deferredFilterValue, debouncedSpeak ] ); /** * Update terms for post. * * @param {number[]} termIds Term ids. */ const onUpdateTerms = ( termIds ) => { editPost( { [ taxonomy.rest_base ]: termIds } ); }; // Stable, so unchanged checkboxes can bail out of re-rendering. const onToggleTerm = useEvent( ( termId ) => { const id = parseInt( termId, 10 ); onUpdateTerms( selectedTerms.has( id ) ? terms.filter( ( term ) => term !== id ) : [ ...terms, id ] ); } ); if ( ! hasAssignAction ) { return null; } /** * Append new term. * * @param {Object} term Term object. * @return {Promise} A promise that resolves to save term object. */ const addTerm = ( term ) => { return saveEntityRecord( 'taxonomy', slug, term, { throwOnError: true, } ); }; const onChangeFormName = ( value ) => { setFormName( value ); }; /** * Handler for changing form parent. * * @param {number|''} parentId Parent post id. */ const onChangeFormParent = ( parentId ) => { setFormParent( parentId ); }; const onToggleForm = () => { setShowForm( ! showForm ); }; const onAddTerm = async ( event ) => { event.preventDefault(); if ( formName === '' || adding ) { return; } // Check if the term we are adding already exists. const existingTerm = findTerm( availableTerms, formParent, formName ); if ( existingTerm ) { // If the term we are adding exists but is not selected select it. if ( ! terms.some( ( term ) => term === existingTerm.id ) ) { onUpdateTerms( [ ...terms, existingTerm.id ] ); } setFormName( '' ); setFormParent( '' ); return; } setAdding( true ); let newTerm; try { newTerm = await addTerm( { name: formName, parent: formParent ? formParent : undefined, } ); } catch ( error ) { createErrorNotice( error.message, { type: 'snackbar', } ); return; } const defaultName = slug === 'category' ? __( 'Category' ) : __( 'Term' ); const termAddedMessage = sprintf( /* translators: %s: term name. */ _x( '%s added', 'term' ), taxonomy?.labels?.singular_name ?? defaultName ); speak( termAddedMessage, 'assertive' ); setAdding( false ); setFormName( '' ); setFormParent( '' ); onUpdateTerms( [ ...terms, newTerm.id ] ); }; const labelWithFallback = ( labelProperty, fallbackIsCategory, fallbackIsNotCategory ) => taxonomy?.labels?.[ labelProperty ] ?? ( slug === 'category' ? fallbackIsCategory : fallbackIsNotCategory ); const newTermButtonLabel = labelWithFallback( 'add_new_item', __( 'Add Category' ), __( 'Add Term' ) ); const newTermLabel = labelWithFallback( 'new_item_name', __( 'Add Category' ), __( 'Add Term' ) ); const parentSelectLabel = labelWithFallback( 'parent_item', __( 'Parent Category' ), __( 'Parent Term' ) ); const noParentOption = `— ${ parentSelectLabel } —`; const newTermSubmitLabel = newTermButtonLabel; const filterLabel = taxonomy?.labels?.search_items ?? __( 'Search Terms' ); const groupLabel = taxonomy?.name ?? __( 'Terms' ); const showFilter = availableTerms.length >= MIN_TERMS_COUNT_FOR_FILTER; return ( <Stack direction="column" gap="lg"> { showFilter && ! loading && ( <SearchControl label={ filterLabel } placeholder={ filterLabel } value={ filterValue } onChange={ setFilterValue } /> ) } { loading && ( <Stack justify="center" style={ { // Match SearchControl height to prevent layout shift. height: '40px', } } > <Spinner /> </Stack> ) } <div className="editor-post-taxonomies__hierarchical-terms-list" tabIndex="0" role="group" aria-label={ groupLabel } > { shownTerms.map( ( term ) => ( <TermRow key={ term.id } term={ term } selectedTerms={ selectedTerms } onToggle={ onToggleTerm } /> ) ) } </div> { ! loading && hasCreateAction && ( <Button __next40pxDefaultSize onClick={ onToggleForm } className="editor-post-taxonomies__hierarchical-terms-add" aria-expanded={ showForm } variant="link" > { newTermButtonLabel } </Button> ) } { showForm && ( <form onSubmit={ onAddTerm }> <Stack direction="column" gap="lg"> <InputControl className="editor-post-taxonomies__hierarchical-terms-input" label={ newTermLabel } value={ formName } onValueChange={ onChangeFormName } required /> { !! availableTerms.length && ( <TreeSelect label={ parentSelectLabel } noOptionLabel={ noParentOption } onChange={ onChangeFormParent } selectedId={ formParent } tree={ availableTermsTree } /> ) } <Button __next40pxDefaultSize variant="secondary" type="submit" className="editor-post-taxonomies__hierarchical-terms-submit" > { newTermSubmitLabel } </Button> </Stack> </form> ) } </Stack> ); } export default withFilters( 'editor.PostTaxonomyType' )( HierarchicalTermSelector );