Skip to content

FlatTermSelector do not work with other object ID #61684

@Rahe

Description

@Rahe

Description

Hello,

I've used the FlatTermSelector component (https://github.com/WordPress/gutenberg/blob/trunk/packages/editor/src/components/post-taxonomies/flat-term-selector.js) (extracted from the sidebar for custom usage), and injected an ID of object to use.
So instead of using

  • getCurrentPost
  • getEditedPostAttribute
  • editPost

I've used

  • useEntityRecord
  • editedRecord[property] returned by useEntityRecord
  • edit function returned by useEntityRecord

The tag selector is working globally, but as soon as I have existing values when adding the term it appears and then disapears almost immeditaly.

The editor indicates that there is a pending modification on the specific object, if I refresh the part that is displaying the selector, the terms are appearing as expected with the new one.

Maybe this behaviour is on the core component too but not visible ?

Consider that the unescapeString, unescapeTerm and most-used-terms have just been extracted from the component base.
I've not changed the global logic.

The edited component :

/**
 * WordPress dependencies
 */
import { __, _x, sprintf } from '@wordpress/i18n';
import { useEffect, useMemo, useState } from '@wordpress/element';
import { FormTokenField } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import {useEntityRecord,store as coreStore} from '@wordpress/core-data';
import { useDebounce } from '@wordpress/compose';
import { speak } from '@wordpress/a11y';
import { store as noticesStore } from '@wordpress/notices';
import { useCallback } from 'react';

/**
 * Internal dependencies
 */
import { unescapeString, unescapeTerm } from '../../Utils';
import MostUsedTerms from './most-used-terms';

/**
 * Shared reference to an empty array for cases where it is important to avoid
 * returning a new array reference on every invocation.
 *
 * @type {Array<any>}
 */
const EMPTY_ARRAY = [];

/**
 * Module constants
 */
const MAX_TERMS_SUGGESTIONS = 20;
const DEFAULT_QUERY = {
	per_page: MAX_TERMS_SUGGESTIONS,
	_fields: 'id,name',
	context: 'view',
};

const isSameTermName = ( termA, termB ) =>
	unescapeString( termA ).toLowerCase() ===
	unescapeString( termB ).toLowerCase();

const termNamesToIds = ( names, terms ) => {
	return names
		.map(
			( termName ) =>
				terms.find( ( term ) => isSameTermName( term.name, termName ) )
					?.id
		)
		.filter( ( id ) => id !== undefined );
};

export function FlatTermSelector( { slug, id, postType } ) {
	const [ values, setValues ] = useState( [] );
	const [ search, setSearch ] = useState( '' );
	const debouncedSearch = useDebounce( setSearch, 500 );

	const post = useEntityRecord(
		'postType',
		postType,
		id
	);

	const editPost = useCallback(
		( tags ) => {
			post.edit( tags );
		},
		[ post.edit ]
	);

	const {
		terms,
		termIds,
		taxonomy,
		hasAssignAction,
		hasCreateAction,
		hasResolvedTerms,
	} = useSelect(
		( select ) => {
			const { getEntityRecord,  getEntityRecords, getTaxonomy, hasFinishedResolution } =
				select( coreStore );

			const _taxonomy = getTaxonomy( slug );

			const _termIds = post.editedRecord[_taxonomy.rest_base];

			const query = {
				...DEFAULT_QUERY,
				include: _termIds.join( ',' ),
				per_page: -1
			};

			return {
				hasCreateAction: _taxonomy
					? post.record._links?.[
							'wp:action-create-' + _taxonomy.rest_base
					  ] ?? false
					: false,
				hasAssignAction: _taxonomy
					? post.record._links?.[
							'wp:action-assign-' + _taxonomy.rest_base
					  ] ?? false
					: false,
				taxonomy: _taxonomy,
				termIds: _termIds,
				terms: _termIds.length
					? getEntityRecords( 'taxonomy', slug, query )
					: EMPTY_ARRAY,
				hasResolvedTerms: hasFinishedResolution( 'getEntityRecords', [
					'taxonomy',
					slug,
					query,
				] )
			};
		},
		[ slug ]
	);

	const { searchResults } = useSelect(
		( select ) => {
			const { getEntityRecords } = select( coreStore );

			return {
				searchResults: !! search
					? getEntityRecords( 'taxonomy', slug, {
							...DEFAULT_QUERY,
							search,
					  } )
					: EMPTY_ARRAY,
			};
		},
		[ search, slug ]
	);

	// Update terms state only after the selectors are resolved.
	// We're using this to avoid terms temporarily disappearing on slow networks
	// while core data makes REST API requests.
	useEffect( () => {
		if ( hasResolvedTerms ) {
			const newValues = ( terms ?? [] ).map( ( term ) =>
				unescapeString( term.name )
			);
			setValues( newValues );
		}
	}, [ terms, hasResolvedTerms ] );

	const suggestions = useMemo( () => {
		return ( searchResults ?? [] ).map( ( term ) =>
			unescapeString( term.name )
		);
	}, [ searchResults ] );

	const { saveEntityRecord } = useDispatch( coreStore );
	const { createErrorNotice } = useDispatch( noticesStore );

	if ( ! hasAssignAction ) {
		return null;
	}

	async function findOrCreateTerm( term ) {
		try {
			const newTerm = await saveEntityRecord( 'taxonomy', slug, term, {
				throwOnError: true,
			} );
			return unescapeTerm( newTerm );
		} catch ( error ) {
			if ( error.code !== 'term_exists' ) {
				throw error;
			}

			return {
				id: error.data.term_id,
				name: term.name,
			};
		}
	}

	function onUpdateTerms( newTermIds ) {
		editPost( { [ taxonomy.rest_base ]: newTermIds } );
	}

	function onChange( termNames ) {
		const availableTerms = [
			...( terms ?? [] ),
			...( searchResults ?? [] ),
		];
		const uniqueTerms = termNames.reduce( ( acc, name ) => {
			if (
				! acc.some( ( n ) => n.toLowerCase() === name.toLowerCase() )
			) {
				acc.push( name );
			}
			return acc;
		}, [] );

		const newTermNames = uniqueTerms.filter(
			( termName ) =>
				! availableTerms.find( ( term ) =>
					isSameTermName( term.name, termName )
				)
		);

		// Optimistically update term values.
		// The selector will always re-fetch terms later.
		setValues( uniqueTerms );

		if ( newTermNames.length === 0 ) {
			onUpdateTerms( termNamesToIds( uniqueTerms, availableTerms ) );
			return;
		}

		if ( ! hasCreateAction ) {
			return;
		}

		Promise.all(
			newTermNames.map( ( termName ) =>
				findOrCreateTerm( { name: termName } )
			)
		)
			.then( ( newTerms ) => {
				const newAvailableTerms = availableTerms.concat( newTerms );

				onUpdateTerms(
					termNamesToIds( uniqueTerms, newAvailableTerms )
				);
			} )
			.catch( ( error ) => {
				createErrorNotice( error.message, {
					type: 'snackbar',
				} );
				// In case of a failure, try assigning available terms.
				// This will invalidate the optimistic update.
				onUpdateTerms( termNamesToIds( uniqueTerms, availableTerms ) );
			} );
	}

	function appendTerm( newTerm ) {
		if ( termIds.includes( newTerm.id ) ) {
			return;
		}

		const newTermIds = [ ...termIds, newTerm.id ];
		const defaultName = slug === 'post_tag' ? __( 'Tag' ) : __( 'Term' );
		const termAddedMessage = sprintf(
			/* translators: %s: term name. */
			_x( '%s added', 'term' ),
			taxonomy?.labels?.singular_name ?? defaultName
		);

		speak( termAddedMessage, 'assertive' );
		onUpdateTerms( newTermIds );
	}

	const newTermLabel =
		taxonomy?.labels?.add_new_item ??
		( slug === 'post_tag' ? __( 'Add new tag' ) : __( 'Add new Term' ) );
	const singularName =
		taxonomy?.labels?.singular_name ??
		( slug === 'post_tag' ? __( 'Tag' ) : __( 'Term' ) );
	const termAddedLabel = sprintf(
		/* translators: %s: term name. */
		_x( '%s added', 'term' ),
		singularName
	);
	const termRemovedLabel = sprintf(
		/* translators: %s: term name. */
		_x( '%s removed', 'term' ),
		singularName
	);
	const removeTermLabel = sprintf(
		/* translators: %s: term name. */
		_x( 'Remove %s', 'term' ),
		singularName
	);

	return (
		<>
			<FormTokenField
				__next40pxDefaultSize
				value={ values }
				suggestions={ suggestions }
				onChange={ onChange }
				onInputChange={ debouncedSearch }
				maxSuggestions={ MAX_TERMS_SUGGESTIONS }
				label={ newTermLabel }
				messages={ {
					added: termAddedLabel,
					removed: termRemovedLabel,
					remove: removeTermLabel,
				} }
			/>
			<MostUsedTerms taxonomy={ taxonomy } onSelect={ appendTerm } />
		</>
	);
}

export default FlatTermSelector;

Example of how I've used it :

	<TagSelector
		slug='category'
		id={12}
		postType="post"
	/>

Step-by-step reproduction instructions

  1. Append the component in sidebar with specific ID and taxonomy
  2. Create new terms
  3. Save
  4. Refresh page and add a new not existing term to the selector
  5. Validate
  6. The list should be updated then get back to previous original list
  7. The editor will notify that this object have an update

Screenshots, screen recording, code snippet

Here is the demo of the bug encountered
https://github.com/WordPress/gutenberg/assets/1007502/3d03265d-6d61-40c2-8325-5d68a3c083bf

Environment info

Latest WordPress
Default theme
wp-env
Chrome

Please confirm that you have searched existing issues in the repo.

Yes

Please confirm that you have tested with all plugins deactivated except Gutenberg.

Yes

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs Technical FeedbackNeeds testing from a developer perspective.[Feature] UI ComponentsImpacts or related to the UI component system[Type] BugAn existing feature does not function as intended

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions