diff --git a/packages/editor/src/bindings/term-data.js b/packages/editor/src/bindings/term-data.js index 8fcb617f570c15..def4e7a8188ec8 100644 --- a/packages/editor/src/bindings/term-data.js +++ b/packages/editor/src/bindings/term-data.js @@ -11,131 +11,43 @@ const NAVIGATION_BLOCK_TYPES = [ 'core/navigation-submenu', ]; -/** - * Creates the data fields object with the given term data values and ID value. - * - * @param {Object} termDataValues The term data values. - * @param {string|number} idValue The ID value to use. - * @return {Object} The data fields object. - */ -function createDataFields( termDataValues, idValue ) { - return { - id: { - label: __( 'Term ID' ), - value: idValue, - type: 'string', - }, - name: { - label: __( 'Name' ), - value: termDataValues?.name, - type: 'string', - }, - slug: { - label: __( 'Slug' ), - value: termDataValues?.slug, - type: 'string', - }, - link: { - label: __( 'Link' ), - value: termDataValues?.link, - type: 'string', - }, - description: { - label: __( 'Description' ), - value: termDataValues?.description, - type: 'string', - }, - parent: { - label: __( 'Parent ID' ), - value: termDataValues?.parent, - type: 'string', - }, - count: { - label: __( 'Count' ), - value: `(${ termDataValues?.count ?? 0 })`, - type: 'string', - }, - }; -} - -/** - * Gets a list of term data fields with their values and labels - * to be consumed in the needed callbacks. - * If the value is not available based on context, like in templates, - * it falls back to the default value, label, or key. - * - * @param {Object} select The select function from the data store. - * @param {Object} context The context provided. - * @param {string} clientId The block client ID used to read attributes. - * @return {Object} List of term data fields with their value and label. - * - * @example - * ```js - * { - * name: { - * label: 'Term Name', - * value: 'Category Name', - * }, - * count: { - * label: 'Term Count', - * value: 5, - * }, - * ... - * } - * ``` - */ -function getTermDataFields( select, context, clientId ) { - const { getEntityRecord } = select( coreDataStore ); - const { getBlockAttributes, getBlockName } = select( blockEditorStore ); - - let termDataValues, dataFields; - - /* - * BACKWARDS COMPATIBILITY: Hardcoded exception for navigation blocks. - * Required for WordPress 6.9+ navigation blocks. DO NOT REMOVE. - */ - const blockName = getBlockName?.( clientId ); - const isNavigationBlock = NAVIGATION_BLOCK_TYPES.includes( blockName ); - - let termId, taxonomy; - - if ( isNavigationBlock ) { - // Navigation blocks: read from block attributes - const blockAttributes = getBlockAttributes?.( clientId ); - termId = blockAttributes?.id; - const typeFromAttributes = blockAttributes?.type; - taxonomy = - typeFromAttributes === 'tag' ? 'post_tag' : typeFromAttributes; - } else { - // All other blocks: use context - termId = context?.termId; - taxonomy = context?.taxonomy; - } - - if ( taxonomy && termId ) { - termDataValues = getEntityRecord( 'taxonomy', taxonomy, termId ); - - if ( ! termDataValues && context?.termData ) { - termDataValues = context.termData; - } - - if ( termDataValues ) { - dataFields = createDataFields( termDataValues, termId ); - } - } else if ( context?.termData ) { - termDataValues = context.termData; - dataFields = createDataFields( - termDataValues, - termDataValues?.term_id - ); - } - - if ( ! dataFields || ! Object.keys( dataFields ).length ) { - return null; - } - - return dataFields; -} +export const termDataFields = [ + { + label: __( 'Term ID' ), + args: { field: 'id' }, + type: 'string', + }, + { + label: __( 'Name' ), + args: { field: 'name' }, + type: 'string', + }, + { + label: __( 'Slug' ), + args: { field: 'slug' }, + type: 'string', + }, + { + label: __( 'Link' ), + args: { field: 'link' }, + type: 'string', + }, + { + label: __( 'Description' ), + args: { field: 'description' }, + type: 'string', + }, + { + label: __( 'Parent ID' ), + args: { field: 'parent' }, + type: 'string', + }, + { + label: __( 'Count' ), + args: { field: 'count' }, + type: 'string', + }, +]; /** * @type {WPBlockBindingsSource} @@ -144,15 +56,67 @@ export default { name: 'core/term-data', usesContext: [ 'taxonomy', 'termId', 'termData' ], getValues( { select, context, bindings, clientId } ) { - const dataFields = getTermDataFields( select, context, clientId ); + const { getEntityRecord } = select( coreDataStore ); + + /* + * BACKWARDS COMPATIBILITY: Hardcoded exception for navigation blocks. + * Required for WordPress 6.9+ navigation blocks. DO NOT REMOVE. + */ + const { getBlockAttributes, getBlockName } = select( blockEditorStore ); + const blockName = getBlockName( clientId ); + const isNavigationBlock = NAVIGATION_BLOCK_TYPES.includes( blockName ); + + let termDataValues; + + if ( isNavigationBlock ) { + // Navigation blocks: read from block attributes + const blockAttributes = getBlockAttributes( clientId ); + const typeFromAttributes = blockAttributes?.type; + const taxonomy = + typeFromAttributes === 'tag' ? 'post_tag' : typeFromAttributes; + termDataValues = getEntityRecord( + 'taxonomy', + taxonomy, + blockAttributes?.id + ); + } else if ( context.termId && context.taxonomy ) { + // All other blocks: use context + termDataValues = getEntityRecord( + 'taxonomy', + context.taxonomy, + context.termId + ); + } + + // Fall back to context termData if available. + if ( ! termDataValues && context?.termData && ! isNavigationBlock ) { + termDataValues = context.termData; + } const newValues = {}; - for ( const [ attributeName, source ] of Object.entries( bindings ) ) { - // Use the value, the field label, or the field key. - const fieldKey = source.args.field; - const { value: fieldValue, label: fieldLabel } = - dataFields?.[ fieldKey ] || {}; - newValues[ attributeName ] = fieldValue ?? fieldLabel ?? fieldKey; + for ( const [ attributeName, binding ] of Object.entries( bindings ) ) { + const termDataField = termDataFields.find( + ( field ) => field.args.field === binding.args.field + ); + + if ( ! termDataField ) { + // If the field is unknown, return the field name. + newValues[ attributeName ] = binding.args.field; + } else if ( + ! termDataValues || + termDataValues[ binding.args.field ] === undefined + ) { + // If the term data does not exist, return the field label. + newValues[ attributeName ] = termDataField.label; + } else if ( binding.args.field === 'count' ) { + // Return the term count value in parentheses. + newValues[ attributeName ] = + '(' + termDataValues[ binding.args.field ] + ')'; + } else { + // If the term data exists, return the term data value. + newValues[ attributeName ] = + termDataValues[ binding.args.field ]; + } } return newValues; }, @@ -161,12 +125,12 @@ export default { // Terms are typically not editable through block bindings in most contexts. return false; }, - canUserEditValue( { select, context, args } ) { + canUserEditValue( { select, context } ) { const { getBlockName, getSelectedBlockClientId } = select( blockEditorStore ); const clientId = getSelectedBlockClientId(); - const blockName = getBlockName?.( clientId ); + const blockName = getBlockName( clientId ); // Navigaton block types are read-only. // See https://github.com/WordPress/gutenberg/pull/72165. @@ -184,26 +148,35 @@ export default { return false; } - const fieldValue = getTermDataFields( select, context, undefined )?.[ - args.field - ]?.value; - // Empty string or `false` could be a valid value, so we need to check if the field value is undefined. - if ( fieldValue === undefined ) { - return false; - } - return false; }, - getFieldsList( { select, context } ) { - const clientId = select( blockEditorStore ).getSelectedBlockClientId(); - const termDataFields = getTermDataFields( select, context, clientId ); - if ( ! termDataFields ) { + getFieldsList( { context, select } ) { + const { getBlockAttributes, getBlockName, getSelectedBlockClientId } = + select( blockEditorStore ); + const clientId = getSelectedBlockClientId(); + const blockName = getBlockName( clientId ); + + if ( NAVIGATION_BLOCK_TYPES.includes( blockName ) ) { + // Navigation blocks: read from block attributes + const blockAttributes = getBlockAttributes( clientId ); + if ( + ! blockAttributes || + ! blockAttributes.id || + ! blockAttributes.type + ) { + return []; + } + return termDataFields; + } + + if ( ! context ) { return []; } - return Object.entries( termDataFields ).map( ( [ key, field ] ) => ( { - label: field.label, - type: field.type, - args: { field: key }, - } ) ); + + if ( ( context.taxonomy && context.termId ) || context.termData ) { + return termDataFields; + } + + return []; }, }; diff --git a/packages/editor/src/bindings/test/term-data.js b/packages/editor/src/bindings/test/term-data.js new file mode 100644 index 00000000000000..3df62585c40e9e --- /dev/null +++ b/packages/editor/src/bindings/test/term-data.js @@ -0,0 +1,406 @@ +/** + * WordPress dependencies + */ +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as coreDataStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { default as termDataBindings, termDataFields } from '../term-data'; + +describe( 'term-data bindings', () => { + const bindings = { + id: { + source: 'core/term-data', + args: { field: 'id' }, + }, + name: { + source: 'core/term-data', + args: { field: 'name' }, + }, + slug: { + source: 'core/term-data', + args: { field: 'slug' }, + }, + link: { + source: 'core/term-data', + args: { field: 'link' }, + }, + description: { + source: 'core/term-data', + args: { field: 'description' }, + }, + parent: { + source: 'core/term-data', + args: { field: 'parent' }, + }, + count: { + source: 'core/term-data', + args: { field: 'count' }, + }, + content: { + source: 'core/term-data', + args: { field: 'unknown' }, + }, + }; + + const getEntityRecordMock = ( kind, taxonomy, termId ) => { + if ( + kind === 'taxonomy' && + taxonomy === 'category' && + termId === 123 + ) { + return { + id: 123, + name: 'Technology', + slug: 'technology', + link: 'https://example.com/category/technology', + parent: 0, + count: 42, + }; + } + return undefined; + }; + + describe( 'getValues', () => { + describe( 'for regular blocks using block context', () => { + describe( 'when termId and taxonomy are provided in context', () => { + const select = ( store ) => { + if ( store === blockEditorStore ) { + return { + getBlockName: ( clientId ) => + clientId === '123abc456' + ? 'core/paragraph' + : undefined, + getBlockAttributes: () => ( {} ), + }; + } + if ( store === coreDataStore ) { + return { + getEntityRecord: getEntityRecordMock, + }; + } + }; + + it( 'should return entity field values when they exist, fall back to field label, and to field name for unknown fields', () => { + const values = termDataBindings.getValues( { + select, + context: { + taxonomy: 'category', + termId: 123, + }, + bindings, + clientId: '123abc456', + } ); + + expect( values ).toStrictEqual( { + id: 123, + name: 'Technology', + slug: 'technology', + link: 'https://example.com/category/technology', + description: 'Description', + parent: 0, + count: '(42)', + content: 'unknown', + } ); + } ); + + it( 'should fall back to field label when entity does not exist, and to field name for unknown fields', () => { + const values = termDataBindings.getValues( { + select, + context: { + taxonomy: 'category', + termId: 456, + }, + bindings, + clientId: '123abc456', + } ); + + expect( values ).toStrictEqual( { + id: 'Term ID', + name: 'Name', + slug: 'Slug', + link: 'Link', + description: 'Description', + parent: 'Parent ID', + count: 'Count', + content: 'unknown', + } ); + } ); + } ); + + describe( 'when termData is provided in context', () => { + const select = ( store ) => { + if ( store === blockEditorStore ) { + return { + getBlockName: () => 'core/paragraph', + getBlockAttributes: () => ( {} ), + }; + } + if ( store === coreDataStore ) { + return { + getEntityRecord: () => null, + }; + } + }; + + const termData = { + id: 456, + name: 'Design', + link: 'https://example.com/category/design', + description: 'Design resources', + parent: 0, + count: 15, + }; + + it( 'should use termData from context when entity record is not available', () => { + const values = termDataBindings.getValues( { + select, + context: { + taxonomy: 'category', + termId: 456, + termData, + }, + bindings, + clientId: '123abc456', + } ); + + expect( values ).toStrictEqual( { + id: 456, + name: 'Design', + slug: 'Slug', + link: 'https://example.com/category/design', + description: 'Design resources', + parent: 0, + count: '(15)', + content: 'unknown', + } ); + } ); + + it( 'should use termData when taxonomy and termId are not provided', () => { + const values = termDataBindings.getValues( { + select, + context: { + termData, + }, + bindings, + clientId: '123abc456', + } ); + + expect( values ).toStrictEqual( { + id: 456, + name: 'Design', + slug: 'Slug', + link: 'https://example.com/category/design', + description: 'Design resources', + parent: 0, + count: '(15)', + content: 'unknown', + } ); + } ); + } ); + } ); + + describe( 'for navigation blocks using block attributes', () => { + it( 'should use block attributes instead of context', () => { + const select = ( store ) => { + if ( store === blockEditorStore ) { + return { + getBlockName: () => 'core/navigation-link', + getBlockAttributes: () => ( { + id: 789, + type: 'category', + } ), + }; + } + if ( store === coreDataStore ) { + return { + getEntityRecord: ( kind, taxonomy, termId ) => { + if ( + kind === 'taxonomy' && + taxonomy === 'category' && + termId === 789 + ) { + return { + id: 789, + name: 'Programming', + slug: 'programming', + link: 'https://example.com/category/programming', + description: 'Programming resources', + parent: 0, + count: 10, + }; + } + return null; + }, + }; + } + }; + + const values = termDataBindings.getValues( { + select, + context: {}, + bindings, + clientId: '123abc456', + } ); + + expect( values ).toStrictEqual( { + id: 789, + name: 'Programming', + slug: 'programming', + link: 'https://example.com/category/programming', + description: 'Programming resources', + parent: 0, + count: '(10)', + content: 'unknown', + } ); + } ); + + it( 'should convert "tag" type to "post_tag" taxonomy', () => { + const select = ( store ) => { + if ( store === blockEditorStore ) { + return { + getBlockName: () => 'core/navigation-link', + getBlockAttributes: () => ( { + id: 321, + type: 'tag', + } ), + }; + } + if ( store === coreDataStore ) { + return { + getEntityRecord: ( kind, taxonomy, termId ) => { + if ( + kind === 'taxonomy' && + taxonomy === 'post_tag' && + termId === 321 + ) { + return { + name: 'JavaScript', + }; + } + return null; + }, + }; + } + }; + + const values = termDataBindings.getValues( { + select, + context: {}, + bindings: { + content: { + source: 'core/term-data', + args: { field: 'name' }, + }, + }, + clientId: '123abc456', + } ); + + expect( values.content ).toBe( 'JavaScript' ); + } ); + } ); + } ); + + describe( 'getFieldsList', () => { + describe( 'when a Navigation block is selected', () => { + it( 'should return the list of available term data fields if id and type attributes are present', () => { + const select = ( store ) => { + if ( store === blockEditorStore ) { + return { + getSelectedBlockClientId: () => '123abc456', + getBlockName: () => 'core/navigation-link', + getBlockAttributes: () => ( { + id: 123, + type: 'category', + } ), + }; + } + if ( store === coreDataStore ) { + return { + getEntityRecord: getEntityRecordMock, + }; + } + }; + const fields = termDataBindings.getFieldsList( { select } ); + + expect( fields ).toEqual( termDataFields ); + } ); + + it( 'should return an empty array if id or type attributes are missing', () => { + const select = ( store ) => { + if ( store === blockEditorStore ) { + return { + getSelectedBlockClientId: () => '123abc456', + getBlockName: () => 'core/navigation-link', + getBlockAttributes: () => ( { type: 'category' } ), + }; + } + if ( store === coreDataStore ) { + return { + getEntityRecord: getEntityRecordMock, + }; + } + }; + const fields = termDataBindings.getFieldsList( { select } ); + + expect( fields ).toEqual( [] ); + } ); + } ); + + describe( 'when a non-Navigation block is selected', () => { + const select = ( store ) => { + if ( store === blockEditorStore ) { + return { + getSelectedBlockClientId: () => '123abc456', + getBlockName: () => 'core/paragraph', + getBlockAttributes: () => ( {} ), + }; + } + if ( store === coreDataStore ) { + return { + getEntityRecord: getEntityRecordMock, + }; + } + }; + + it( 'should return the list of available term data fields when taxonomy and termId are provided by context', () => { + const fields = termDataBindings.getFieldsList( { + select, + context: { taxonomy: 'category', termId: 123 }, + } ); + + expect( fields ).toEqual( termDataFields ); + } ); + + it( 'should return empty array when neither termId nor termData is provided from context', () => { + const fields = termDataBindings.getFieldsList( { + select, + context: { taxonomy: 'category' }, + } ); + + expect( fields ).toEqual( [] ); + } ); + + it( 'should return fields when using termData from context', () => { + const fields = termDataBindings.getFieldsList( { + select, + context: { + termData: { + id: 456, + name: 'Design', + slug: 'design', + link: 'https://example.com/category/design', + description: 'Design resources', + parent: 0, + count: 15, + }, + }, + } ); + + expect( fields ).toEqual( termDataFields ); + } ); + } ); + } ); +} );