diff --git a/backport-changelog/6.9/10305.md b/backport-changelog/6.9/10305.md new file mode 100644 index 00000000000000..d2735eb045f294 --- /dev/null +++ b/backport-changelog/6.9/10305.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/10305 + +* https://github.com/WordPress/gutenberg/pull/72165 diff --git a/lib/compat/wordpress-6.9/entity-block-bindings.php b/lib/compat/wordpress-6.9/entity-block-bindings.php deleted file mode 100644 index bdd949976e7a88..00000000000000 --- a/lib/compat/wordpress-6.9/entity-block-bindings.php +++ /dev/null @@ -1,106 +0,0 @@ - "url" ). - * @param WP_Block $block_instance The block instance. - * @return mixed The value computed for the source. - */ -function gutenberg_block_bindings_entity_get_value( array $source_args, $block_instance ) { - // Get the key from source args - no key means invalid binding - if ( empty( $source_args['key'] ) ) { - return null; - } - - $key = $source_args['key']; - - // For now, only support 'url' key - if ( 'url' !== $key ) { - return null; - } - - // Read entity data from block attributes - $entity_id = $block_instance->attributes['id'] ?? null; - $type = $block_instance->attributes['type'] ?? ''; - $kind = $block_instance->attributes['kind'] ?? ''; - - if ( empty( $entity_id ) ) { - return null; - } - - try { - // Handle post types - if ( 'post-type' === $kind ) { - $post = get_post( $entity_id ); - if ( ! $post ) { - return null; - } - - $permalink = get_permalink( $entity_id ); - if ( is_wp_error( $permalink ) ) { - return null; - } - - return esc_url( $permalink ); - } - - // Handle taxonomies - if ( 'taxonomy' === $kind ) { - // Convert 'tag' back to 'post_tag' for API calls - // See update-attributes.js line 166 for the reverse conversion - $taxonomy_slug = ( 'tag' === $type ) ? 'post_tag' : $type; - $term = get_term( $entity_id, $taxonomy_slug ); - - if ( is_wp_error( $term ) || ! $term ) { - return null; - } - - $term_link = get_term_link( $term ); - if ( is_wp_error( $term_link ) ) { - return null; - } - - return esc_url( $term_link ); - } - - // Unknown entity kind - return null; - } catch ( Exception $e ) { - return null; - } -} - -/** - * Registers Entity source in the block bindings registry. - * - * @since 6.9.0 - * @access private - */ -function gutenberg_register_block_bindings_entity_source() { - if ( get_block_bindings_source( 'core/entity' ) ) { - // The source is already registered. - return; - } - - register_block_bindings_source( - 'core/entity', - array( - 'label' => _x( 'Entity', 'block bindings source' ), - 'get_value_callback' => 'gutenberg_block_bindings_entity_get_value', - ) - ); -} - -add_action( 'init', 'gutenberg_register_block_bindings_entity_source' ); diff --git a/lib/compat/wordpress-6.9/post-data-block-bindings.php b/lib/compat/wordpress-6.9/post-data-block-bindings.php index 4359b56c5e4b37..18567fc63dc8d5 100644 --- a/lib/compat/wordpress-6.9/post-data-block-bindings.php +++ b/lib/compat/wordpress-6.9/post-data-block-bindings.php @@ -23,10 +23,29 @@ function gutenberg_block_bindings_post_data_get_value( array $source_args, $bloc return null; } - if ( empty( $block_instance->context['postId'] ) ) { + /* + * BACKWARDS COMPATIBILITY: Hardcoded exception for navigation blocks. + * Required for WordPress 6.9+ navigation blocks. DO NOT REMOVE. + */ + $block_name = $block_instance->name ?? ''; + $is_navigation_block = in_array( + $block_name, + array( 'core/navigation-link', 'core/navigation-submenu' ), + true + ); + + if ( $is_navigation_block ) { + // Navigation blocks: read from block attributes + $post_id = $block_instance->attributes['id'] ?? null; + } else { + // All other blocks: use context + $post_id = $block_instance->context['postId'] ?? null; + } + + // If we don't have an entity ID, bail early. + if ( empty( $post_id ) ) { return null; } - $post_id = $block_instance->context['postId']; // If a post isn't public, we need to prevent unauthorized users from accessing the post data. $post = get_post( $post_id ); @@ -46,6 +65,11 @@ function gutenberg_block_bindings_post_data_get_value( array $source_args, $bloc return ''; } } + + if ( 'link' === $source_args['key'] ) { + $permalink = get_permalink( $post_id ); + return false === $permalink ? null : esc_url( $permalink ); + } } /** diff --git a/lib/compat/wordpress-6.9/term-data-block-bindings.php b/lib/compat/wordpress-6.9/term-data-block-bindings.php new file mode 100644 index 00000000000000..9686af6ce976e8 --- /dev/null +++ b/lib/compat/wordpress-6.9/term-data-block-bindings.php @@ -0,0 +1,119 @@ + "name" ). + * @param WP_Block $block_instance The block instance. + * @return mixed The value computed for the source. + */ +function gutenberg_block_bindings_term_data_get_value( array $source_args, $block_instance ) { + if ( empty( $source_args['key'] ) ) { + return null; + } + + /* + * BACKWARDS COMPATIBILITY: Hardcoded exception for navigation blocks. + * Required for WordPress 6.9+ navigation blocks. DO NOT REMOVE. + */ + $block_name = $block_instance->name ?? ''; + $is_navigation_block = in_array( + $block_name, + array( 'core/navigation-link', 'core/navigation-submenu' ), + true + ); + + if ( $is_navigation_block ) { + // Navigation blocks: read from block attributes + $term_id = $block_instance->attributes['id'] ?? null; + $type = $block_instance->attributes['type'] ?? ''; + // Map UI shorthand to taxonomy slug when using attributes. + $taxonomy = ( 'tag' === $type ) ? 'post_tag' : $type; + } else { + // All other blocks: use context + $term_id = $block_instance->context['termId'] ?? null; + $taxonomy = $block_instance->context['taxonomy'] ?? ''; + } + + // If we don't have required identifiers, bail early. + if ( empty( $term_id ) || empty( $taxonomy ) ) { + return null; + } + + // Get the term data. + $term = get_term( $term_id, $taxonomy ); + if ( is_wp_error( $term ) || ! $term ) { + return null; + } + + // Check if taxonomy exists and is publicly queryable. + $taxonomy_object = get_taxonomy( $taxonomy ); + if ( ! $taxonomy_object || ! $taxonomy_object->publicly_queryable ) { + if ( ! current_user_can( 'read' ) ) { + return null; + } + } + + switch ( $source_args['key'] ) { + case 'id': + return esc_html( (string) $term_id ); + + case 'name': + return esc_html( $term->name ); + + case 'link': + // Only taxonomy entities are supported by Term Data. + $term_link = get_term_link( $term ); + return is_wp_error( $term_link ) ? null : esc_url( $term_link ); + + case 'slug': + return esc_html( $term->slug ); + + case 'description': + return wp_kses_post( $term->description ); + + case 'parent': + return esc_html( (string) $term->parent ); + + case 'count': + return esc_html( (string) $term->count ); + + default: + return null; + } +} + +/** + * Registers Term Data source in the block bindings registry. + * + * @since 6.9.0 + * @access private + */ +function gutenberg_register_block_bindings_term_data_source() { + if ( get_block_bindings_source( 'core/term-data' ) ) { + // The source is already registered. + return; + } + + register_block_bindings_source( + 'core/term-data', + array( + 'label' => _x( 'Term Data', 'block bindings source' ), + 'get_value_callback' => 'gutenberg_block_bindings_term_data_get_value', + 'uses_context' => array( 'termId', 'taxonomy' ), + ) + ); +} + +add_action( 'init', 'gutenberg_register_block_bindings_term_data_source' ); diff --git a/lib/load.php b/lib/load.php index 486bcfc54a916e..7144da0cd6daad 100644 --- a/lib/load.php +++ b/lib/load.php @@ -44,7 +44,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-6.9/template-activate.php'; require __DIR__ . '/compat/wordpress-6.9/block-bindings.php'; require __DIR__ . '/compat/wordpress-6.9/post-data-block-bindings.php'; - require __DIR__ . '/compat/wordpress-6.9/entity-block-bindings.php'; + require __DIR__ . '/compat/wordpress-6.9/term-data-block-bindings.php'; require __DIR__ . '/compat/wordpress-6.9/rest-api.php'; require __DIR__ . '/compat/wordpress-6.9/class-gutenberg-hierarchical-sort.php'; require __DIR__ . '/compat/wordpress-6.9/block-comments.php'; diff --git a/packages/block-editor/src/components/link-control/test/index.js b/packages/block-editor/src/components/link-control/test/index.js index aa7a72c0d5a985..1f81bc6b5a17ec 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -1518,17 +1518,18 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { await user.click( createButton ); - searchInput = screen.getByRole( 'combobox', { - name: 'Search or type URL', - } ); - - const errorNotice = screen.getAllByText( + // Wait for the error message to appear after the async operation fails + const errorNotice = await screen.findByText( 'API response returned invalid entity.' - )[ 1 ]; + ); // Catch the error in the test to avoid test failures. expect( throwsError ).toThrow( Error ); + searchInput = screen.getByRole( 'combobox', { + name: 'Search or type URL', + } ); + // Check human readable error notice is perceivable. expect( errorNotice ).toBeVisible(); // eslint-disable-next-line testing-library/no-node-access diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index 69212085734715..0c45dec91468a4 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -543,7 +543,10 @@ export default function NavigationLinkEdit( { anchor={ popoverAnchor } onRemove={ removeLink } onChange={ ( updatedValue ) => { - const { isEntityLink } = updateAttributes( + const { + isEntityLink, + attributes: updatedAttributes, + } = updateAttributes( updatedValue, setAttributes, attributes @@ -553,9 +556,9 @@ export default function NavigationLinkEdit( { // Only create bindings for entity links (posts, pages, taxonomies) // Never create bindings for custom links (manual URLs) if ( isEntityLink ) { - createBinding(); + createBinding( updatedAttributes ); } else { - clearBinding(); + clearBinding( updatedAttributes ); } } } /> diff --git a/packages/block-library/src/navigation-link/link-ui/index.js b/packages/block-library/src/navigation-link/link-ui/index.js index dc41233050ed26..8ab3792943f4dc 100644 --- a/packages/block-library/src/navigation-link/link-ui/index.js +++ b/packages/block-library/src/navigation-link/link-ui/index.js @@ -78,10 +78,12 @@ function UnforwardedLinkUI( props, ref ) { name: postType, } ); - // Check if there's a URL binding with the core/entity source + // Check if there's a URL binding with the new binding sources // Only enable handleEntities when there's actually a binding present const hasUrlBinding = - metadata?.bindings?.url?.source === 'core/entity' && !! id; + ( metadata?.bindings?.url?.source === 'core/post-data' || + metadata?.bindings?.url?.source === 'core/term-data' ) && + !! id; // Memoize link value to avoid overriding the LinkControl's internal state. // This is a temporary fix. See https://github.com/WordPress/gutenberg/issues/50976#issuecomment-1568226407. diff --git a/packages/block-library/src/navigation-link/shared/controls.js b/packages/block-library/src/navigation-link/shared/controls.js index 420ea58a83505c..f45dedea9c9223 100644 --- a/packages/block-library/src/navigation-link/shared/controls.js +++ b/packages/block-library/src/navigation-link/shared/controls.js @@ -16,6 +16,8 @@ import { useInstanceId } from '@wordpress/compose'; import { safeDecodeURI } from '@wordpress/url'; import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; import { linkOff as unlinkIcon } from '@wordpress/icons'; +import { useDispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; /** * Internal dependencies @@ -79,12 +81,19 @@ export function Controls( { attributes, setAttributes, clientId } ) { attributes, } ); + // Get direct store dispatch to bypass setBoundAttributes wrapper + const { updateBlockAttributes } = useDispatch( blockEditorStore ); + const editBoundLink = () => { - // Remove the binding + // Clear the binding first clearBinding(); - // Clear url and id to allow picking a new entity (keep type and kind) - setAttributes( { url: undefined, id: undefined } ); + // Use direct store dispatch to bypass block bindings safeguards + // which prevent updates to bound attributes when calling setAttributes. + // setAttributes is actually setBoundAttributes, a wrapper function that + // processes attributes through the binding system. + // See: packages/block-editor/src/components/block-edit/edit.js + updateBlockAttributes( clientId, { url: '', id: undefined } ); }; return ( diff --git a/packages/block-library/src/navigation-link/shared/test/update-attributes.test.js b/packages/block-library/src/navigation-link/shared/test/update-attributes.test.js index 9337028e96f75f..3767909d545406 100644 --- a/packages/block-library/src/navigation-link/shared/test/update-attributes.test.js +++ b/packages/block-library/src/navigation-link/shared/test/update-attributes.test.js @@ -1153,6 +1153,7 @@ describe( 'updateAttributes', () => { expect( result ).toEqual( { isEntityLink: true, + attributes: expect.any( Object ), } ); } ); @@ -1173,6 +1174,7 @@ describe( 'updateAttributes', () => { expect( result ).toEqual( { isEntityLink: false, + attributes: expect.any( Object ), } ); } ); @@ -1190,6 +1192,7 @@ describe( 'updateAttributes', () => { expect( result ).toEqual( { isEntityLink: false, + attributes: expect.any( Object ), } ); } ); @@ -1215,6 +1218,7 @@ describe( 'updateAttributes', () => { // Should return false because the link was severed and converted to custom expect( result ).toEqual( { isEntityLink: false, + attributes: expect.any( Object ), } ); } ); @@ -1240,6 +1244,7 @@ describe( 'updateAttributes', () => { // Should return true because entity link is preserved expect( result ).toEqual( { isEntityLink: true, + attributes: expect.any( Object ), } ); } ); @@ -1260,6 +1265,7 @@ describe( 'updateAttributes', () => { // mailto links have kind: 'custom', so isEntityLink should be false expect( result ).toEqual( { isEntityLink: false, + attributes: expect.any( Object ), } ); } ); @@ -1280,6 +1286,7 @@ describe( 'updateAttributes', () => { // tel links have kind: 'custom', so isEntityLink should be false expect( result ).toEqual( { isEntityLink: false, + attributes: expect.any( Object ), } ); } ); @@ -1300,6 +1307,7 @@ describe( 'updateAttributes', () => { expect( result ).toEqual( { isEntityLink: true, + attributes: expect.any( Object ), } ); } ); } ); diff --git a/packages/block-library/src/navigation-link/shared/test/use-entity-binding.js b/packages/block-library/src/navigation-link/shared/test/use-entity-binding.js index 62513e1d55b178..31636c9e8cb8ae 100644 --- a/packages/block-library/src/navigation-link/shared/test/use-entity-binding.js +++ b/packages/block-library/src/navigation-link/shared/test/use-entity-binding.js @@ -49,17 +49,42 @@ describe( 'useEntityBinding', () => { expect( result.current.hasUrlBinding ).toBe( false ); } ); - it( 'should return true when core/entity binding exists with id', () => { + it( 'should return true when core/post-data binding exists with id for post-type', () => { const attributes = { metadata: { bindings: { url: { - source: 'core/entity', - args: { key: 'url' }, + source: 'core/post-data', + args: { key: 'link' }, + }, + }, + }, + id: 123, + kind: 'post-type', + }; + + const { result } = renderHook( () => + useEntityBinding( { + clientId: 'test-client-id', + attributes, + } ) + ); + + expect( result.current.hasUrlBinding ).toBe( true ); + } ); + + it( 'should return true when core/term-data binding exists with id for taxonomy', () => { + const attributes = { + metadata: { + bindings: { + url: { + source: 'core/term-data', + args: { key: 'link' }, }, }, }, id: 123, + kind: 'taxonomy', }; const { result } = renderHook( () => @@ -72,7 +97,7 @@ describe( 'useEntityBinding', () => { expect( result.current.hasUrlBinding ).toBe( true ); } ); - it( 'should return false when source is not core/entity', () => { + it( 'should return false when source is not core/post-data or core/term-data', () => { const attributes = { metadata: { bindings: { @@ -83,6 +108,7 @@ describe( 'useEntityBinding', () => { }, }, id: 123, + kind: 'post-type', }; const { result } = renderHook( () => @@ -95,17 +121,18 @@ describe( 'useEntityBinding', () => { expect( result.current.hasUrlBinding ).toBe( false ); } ); - it( 'should return false when core/entity binding exists but no id', () => { + it( 'should return false when core/post-data binding exists but no id', () => { const attributes = { metadata: { bindings: { url: { - source: 'core/entity', - args: { key: 'url' }, + source: 'core/post-data', + args: { key: 'link' }, }, }, }, id: null, + kind: 'post-type', }; const { result } = renderHook( () => @@ -147,12 +174,13 @@ describe( 'useEntityBinding', () => { metadata: { bindings: { url: { - source: 'core/entity', - args: { key: 'url' }, + source: 'core/post-data', + args: { key: 'link' }, }, }, }, id: 123, + kind: 'post-type', }; const { result } = renderHook( () => @@ -171,7 +199,7 @@ describe( 'useEntityBinding', () => { } ); } ); - it( 'should NOT clear binding when clearBinding is called and no binding exists', () => { + it( 'should NOT call updateBlockBindings when clearBinding is called and no binding exists', () => { const attributes = { metadata: {}, id: null, @@ -191,7 +219,7 @@ describe( 'useEntityBinding', () => { expect( mockUpdateBlockBindings ).not.toHaveBeenCalled(); } ); - it( 'should NOT clear binding when binding metadata exists but source is null', () => { + it( 'should call updateBlockBindings when clearBinding is called and binding exists even with null source', () => { const attributes = { metadata: { bindings: { @@ -215,13 +243,16 @@ describe( 'useEntityBinding', () => { result.current.clearBinding(); } ); - expect( mockUpdateBlockBindings ).not.toHaveBeenCalled(); + expect( mockUpdateBlockBindings ).toHaveBeenCalledWith( { + url: undefined, + } ); } ); - it( 'should create binding when createBinding is called', () => { + it( 'should create core/post-data binding when createBinding is called for post-type', () => { const attributes = { metadata: {}, id: null, + kind: 'post-type', }; const { result } = renderHook( () => @@ -237,11 +268,214 @@ describe( 'useEntityBinding', () => { expect( mockUpdateBlockBindings ).toHaveBeenCalledWith( { url: { - source: 'core/entity', + source: 'core/post-data', args: { - key: 'url', + key: 'link', }, }, } ); } ); + + it( 'should create core/term-data binding when createBinding is called for taxonomy', () => { + const attributes = { + metadata: {}, + id: null, + kind: 'taxonomy', + }; + + const { result } = renderHook( () => + useEntityBinding( { + clientId: 'test-client-id', + attributes, + } ) + ); + + act( () => { + result.current.createBinding(); + } ); + + expect( mockUpdateBlockBindings ).toHaveBeenCalledWith( { + url: { + source: 'core/term-data', + args: { + key: 'link', + }, + }, + } ); + } ); + + describe( 'clearBinding behavior', () => { + it( 'should call updateBlockBindings when clearBinding is called and valid binding exists', () => { + const attributes = { + metadata: { + bindings: { + url: { + source: 'core/post-data', + args: { key: 'link' }, + }, + }, + }, + id: 123, + kind: 'post-type', + }; + + const { result } = renderHook( () => + useEntityBinding( { + clientId: 'test-client-id', + attributes, + } ) + ); + + act( () => { + result.current.clearBinding(); + } ); + + expect( mockUpdateBlockBindings ).toHaveBeenCalledWith( { + url: undefined, + } ); + } ); + + it( 'should call updateBlockBindings when clearBinding is called and valid taxonomy binding exists', () => { + const attributes = { + metadata: { + bindings: { + url: { + source: 'core/term-data', + args: { key: 'link' }, + }, + }, + }, + id: 456, + kind: 'taxonomy', + }; + + const { result } = renderHook( () => + useEntityBinding( { + clientId: 'test-client-id', + attributes, + } ) + ); + + act( () => { + result.current.clearBinding(); + } ); + + expect( mockUpdateBlockBindings ).toHaveBeenCalledWith( { + url: undefined, + } ); + } ); + + it( 'should NOT call updateBlockBindings when clearBinding is called and binding exists but no id', () => { + const attributes = { + metadata: { + bindings: { + url: { + source: 'core/post-data', + args: { key: 'link' }, + }, + }, + }, + id: null, + kind: 'post-type', + }; + + const { result } = renderHook( () => + useEntityBinding( { + clientId: 'test-client-id', + attributes, + } ) + ); + + act( () => { + result.current.clearBinding(); + } ); + + expect( mockUpdateBlockBindings ).not.toHaveBeenCalled(); + } ); + + it( 'should call updateBlockBindings when clearBinding is called and binding exists with any source', () => { + const attributes = { + metadata: { + bindings: { + url: { + source: 'core/post-data', + args: { key: 'link' }, + }, + }, + }, + id: 123, + kind: 'post-type', // Correct kind for post-data source + }; + + const { result } = renderHook( () => + useEntityBinding( { + clientId: 'test-client-id', + attributes, + } ) + ); + + act( () => { + result.current.clearBinding(); + } ); + + expect( mockUpdateBlockBindings ).toHaveBeenCalledWith( { + url: undefined, + } ); + } ); + } ); + + describe( 'createBinding behavior', () => { + it( 'should not create binding when createBinding is called without kind', () => { + const attributes = { + metadata: {}, + id: null, + kind: null, + }; + + const { result } = renderHook( () => + useEntityBinding( { + clientId: 'test-client-id', + attributes, + } ) + ); + + act( () => { + result.current.createBinding(); + } ); + + expect( mockUpdateBlockBindings ).not.toHaveBeenCalled(); + } ); + + it( 'should create binding with updated attributes when createBinding is called with updatedAttributes', () => { + const attributes = { + metadata: {}, + id: null, + kind: 'post-type', + }; + + const updatedAttributes = { + kind: 'taxonomy', + }; + + const { result } = renderHook( () => + useEntityBinding( { + clientId: 'test-client-id', + attributes, + } ) + ); + + act( () => { + result.current.createBinding( updatedAttributes ); + } ); + + expect( mockUpdateBlockBindings ).toHaveBeenCalledWith( { + url: { + source: 'core/term-data', + args: { + key: 'link', + }, + }, + } ); + } ); + } ); } ); diff --git a/packages/block-library/src/navigation-link/shared/update-attributes.js b/packages/block-library/src/navigation-link/shared/update-attributes.js index 067e71ddf1cb16..629f319ece306e 100644 --- a/packages/block-library/src/navigation-link/shared/update-attributes.js +++ b/packages/block-library/src/navigation-link/shared/update-attributes.js @@ -222,5 +222,6 @@ export const updateAttributes = ( return { isEntityLink: !! finalId && finalKind !== 'custom', + attributes, // Return the computed attributes object }; }; diff --git a/packages/block-library/src/navigation-link/shared/use-entity-binding.js b/packages/block-library/src/navigation-link/shared/use-entity-binding.js index a24127d1004a31..6e164f24955e71 100644 --- a/packages/block-library/src/navigation-link/shared/use-entity-binding.js +++ b/packages/block-library/src/navigation-link/shared/use-entity-binding.js @@ -17,33 +17,50 @@ import { useBlockBindingsUtils } from '@wordpress/block-editor'; */ export function useEntityBinding( { clientId, attributes } ) { const { updateBlockBindings } = useBlockBindingsUtils( clientId ); - const { metadata, id } = attributes; + const { metadata, id, kind } = attributes; - // Check if there's a URL binding with the core/entity source - const hasUrlBinding = - metadata?.bindings?.url?.source === 'core/entity' && !! id; + const hasUrlBinding = !! metadata?.bindings?.url && !! id; + const expectedSource = + kind === 'post-type' ? 'core/post-data' : 'core/term-data'; + const hasCorrectBinding = + hasUrlBinding && metadata?.bindings?.url?.source === expectedSource; const clearBinding = useCallback( () => { - // Only clear if there's actually a valid binding to clear if ( hasUrlBinding ) { - // Remove the URL binding by setting it to undefined updateBlockBindings( { url: undefined } ); } - }, [ hasUrlBinding, updateBlockBindings ] ); - - const createBinding = useCallback( () => { - updateBlockBindings( { - url: { - source: 'core/entity', - args: { - key: 'url', + }, [ updateBlockBindings, hasUrlBinding, metadata, id ] ); + + const createBinding = useCallback( + ( updatedAttributes ) => { + // Use updated attributes if provided, otherwise fall back to closure attributes + // updatedAttributes needed to access the most up-to-date data when called synchronously + const kindToUse = updatedAttributes?.kind ?? kind; + + // Avoid creating binding if no kind is provided + if ( ! kindToUse ) { + return; + } + + // Default to post-type in case there is a need to support dynamic kinds + // in the future. + const source = + kindToUse === 'taxonomy' ? 'core/term-data' : 'core/post-data'; + + updateBlockBindings( { + url: { + source, + args: { + key: 'link', + }, }, - }, - } ); - }, [ updateBlockBindings ] ); + } ); + }, + [ updateBlockBindings, kind, id ] + ); return { - hasUrlBinding, + hasUrlBinding: hasCorrectBinding, clearBinding, createBinding, }; diff --git a/packages/block-library/src/navigation-submenu/edit.js b/packages/block-library/src/navigation-submenu/edit.js index 3857816b066017..46a9d06ce5c114 100644 --- a/packages/block-library/src/navigation-submenu/edit.js +++ b/packages/block-library/src/navigation-submenu/edit.js @@ -432,7 +432,10 @@ export default function NavigationSubmenuEdit( { } } onChange={ ( updatedValue ) => { // updateAttributes determines the final state and returns metadata - const { isEntityLink } = updateAttributes( + const { + isEntityLink, + attributes: updatedAttributes, + } = updateAttributes( updatedValue, setAttributes, attributes @@ -442,9 +445,9 @@ export default function NavigationSubmenuEdit( { // Only create bindings for entity links (posts, pages, taxonomies) // Never create bindings for custom links (manual URLs) if ( isEntityLink ) { - createBinding(); + createBinding( updatedAttributes ); } else { - clearBinding(); + clearBinding( updatedAttributes ); } } } /> diff --git a/packages/block-library/src/navigation/edit/menu-inspector-controls.js b/packages/block-library/src/navigation/edit/menu-inspector-controls.js index 5302acfe92d101..1738497e938a9f 100644 --- a/packages/block-library/src/navigation/edit/menu-inspector-controls.js +++ b/packages/block-library/src/navigation/edit/menu-inspector-controls.js @@ -111,19 +111,20 @@ function AdditionalBlockContent( { block, insertedBlock, setInsertedBlock } ) { } } onChange={ ( updatedValue ) => { // updateAttributes determines the final state and returns metadata - const { isEntityLink } = updateAttributes( - updatedValue, - setInsertedBlockAttributes( insertedBlock?.clientId ), - insertedBlock?.attributes - ); + const { isEntityLink, attributes: updatedAttributes } = + updateAttributes( + updatedValue, + setInsertedBlockAttributes( insertedBlock?.clientId ), + insertedBlock?.attributes + ); // Handle URL binding based on the final computed state // Only create bindings for entity links (posts, pages, taxonomies) // Never create bindings for custom links (manual URLs) if ( isEntityLink ) { - createBinding(); + createBinding( updatedAttributes ); } else { - clearBinding(); + clearBinding( updatedAttributes ); } setInsertedBlock( null ); diff --git a/packages/editor/src/bindings/api.js b/packages/editor/src/bindings/api.js index 844b2cfba1f0bc..d25ac70d9b84a0 100644 --- a/packages/editor/src/bindings/api.js +++ b/packages/editor/src/bindings/api.js @@ -9,7 +9,7 @@ import { registerBlockBindingsSource } from '@wordpress/blocks'; import patternOverrides from './pattern-overrides'; import postData from './post-data'; import postMeta from './post-meta'; -import entity from './entity'; +import termData from './term-data'; /** * Function to register core block bindings sources provided by the editor. @@ -25,5 +25,5 @@ export function registerCoreBlockBindingsSources() { registerBlockBindingsSource( patternOverrides ); registerBlockBindingsSource( postData ); registerBlockBindingsSource( postMeta ); - registerBlockBindingsSource( entity ); + registerBlockBindingsSource( termData ); } diff --git a/packages/editor/src/bindings/entity.js b/packages/editor/src/bindings/entity.js deleted file mode 100644 index 2e6a4a02c93f2b..00000000000000 --- a/packages/editor/src/bindings/entity.js +++ /dev/null @@ -1,89 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { store as coreDataStore } from '@wordpress/core-data'; - -export default { - name: 'core/entity', - label: __( 'Entity' ), - getValues( { select, clientId, bindings } ) { - const { getBlockAttributes } = select( 'core/block-editor' ); - - // Get the nav link's id attribute - const blockAttributes = getBlockAttributes( clientId ); - const entityId = blockAttributes?.id; - - if ( ! entityId ) { - return {}; - } - - // Get the key from binding args - no key means invalid binding - const urlBinding = bindings.url; - if ( ! urlBinding?.args?.key ) { - return {}; - } - - const key = urlBinding.args.key; - - // For now, only support 'url' key - if ( key !== 'url' ) { - return {}; - } - - // Get the entity type and kind from block attributes - const { type, kind } = blockAttributes || {}; - - // Validate required attributes exist - if ( ! type || ! kind ) { - return {}; - } - - // Validate entity kind is supported - if ( kind !== 'post-type' && kind !== 'taxonomy' ) { - return {}; - } - - const { getEntityRecord } = select( coreDataStore ); - let value = ''; - - // Handle post types - if ( kind === 'post-type' ) { - const post = getEntityRecord( 'postType', type, entityId ); - - if ( ! post ) { - return {}; - } - - value = post.link || ''; - } - // Handle taxonomies - else if ( kind === 'taxonomy' ) { - // Convert 'tag' back to 'post_tag' for API calls - // See https://github.com/WordPress/gutenberg/issues/71979. - const taxonomySlug = type === 'tag' ? 'post_tag' : type; - const term = getEntityRecord( 'taxonomy', taxonomySlug, entityId ); - - if ( ! term ) { - return {}; - } - - value = term.link || ''; - } - - // If we couldn't get a valid URL, return empty object - if ( ! value ) { - return {}; - } - - return { - url: value, - }; - }, - canUserEditValue() { - // This binding source provides read-only URLs derived from entity data - // Users cannot manually edit these values as they are automatically - // generated from the linked post/term's permalink - return false; - }, -}; diff --git a/packages/editor/src/bindings/post-data.js b/packages/editor/src/bindings/post-data.js index 2112680b657361..74038d9b15bc3c 100644 --- a/packages/editor/src/bindings/post-data.js +++ b/packages/editor/src/bindings/post-data.js @@ -3,6 +3,13 @@ */ import { __ } from '@wordpress/i18n'; import { store as coreDataStore } from '@wordpress/core-data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +// Navigation block types that use special handling for backwards compatibility +const NAVIGATION_BLOCK_TYPES = [ + 'core/navigation-link', + 'core/navigation-submenu', +]; /** * Gets a list of post data fields with their values and labels @@ -10,8 +17,9 @@ import { store as coreDataStore } from '@wordpress/core-data'; * 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 {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 post data fields with their value and label. * * @example @@ -29,16 +37,38 @@ import { store as coreDataStore } from '@wordpress/core-data'; * } * ``` */ -function getPostDataFields( select, context ) { +function getPostDataFields( select, context, clientId ) { const { getEditedEntityRecord } = select( coreDataStore ); + const { getBlockAttributes, getBlockName } = select( blockEditorStore ); let entityDataValues, dataFields; - // Try to get the current entity data values. - if ( context?.postType && context?.postId ) { + + /* + * 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 postId, postType; + + if ( isNavigationBlock ) { + // Navigation blocks: read from block attributes + const blockAttributes = getBlockAttributes?.( clientId ); + postId = blockAttributes?.id; + postType = blockAttributes?.type; + } else { + // All other blocks: use context + postId = context?.postId; + postType = context?.postType; + } + + // Try to get the current entity data values using resolved identifiers. + if ( postType && postId ) { entityDataValues = getEditedEntityRecord( 'postType', - context?.postType, - context?.postId + postType, + postId ); dataFields = { date: { @@ -51,6 +81,11 @@ function getPostDataFields( select, context ) { value: entityDataValues?.modified, type: 'string', }, + link: { + label: __( 'Post Link' ), + value: entityDataValues?.link, + type: 'string', + }, }; } @@ -66,8 +101,8 @@ function getPostDataFields( select, context ) { */ export default { name: 'core/post-data', - getValues( { select, context, bindings } ) { - const dataFields = getPostDataFields( select, context ); + getValues( { select, context, bindings, clientId } ) { + const dataFields = getPostDataFields( select, context, clientId ); const newValues = {}; for ( const [ attributeName, source ] of Object.entries( bindings ) ) { @@ -79,7 +114,16 @@ export default { } return newValues; }, - setValues( { dispatch, context, bindings } ) { + setValues( { dispatch, context, bindings, clientId, select } ) { + const { getBlockName } = select( blockEditorStore ); + + const blockName = getBlockName?.( clientId ); + + // Navigaton block types are read-only. + // See https://github.com/WordPress/gutenberg/pull/72165. + if ( NAVIGATION_BLOCK_TYPES.includes( blockName ) ) { + return false; + } const newData = {}; Object.values( bindings ).forEach( ( { args, newValue } ) => { newData[ args.key ] = newValue; @@ -93,6 +137,17 @@ export default { ); }, canUserEditValue( { select, context, args } ) { + const { getBlockName, getSelectedBlockClientId } = + select( blockEditorStore ); + const clientId = getSelectedBlockClientId(); + const blockName = getBlockName?.( clientId ); + + // Navigaton block types are read-only. + // See https://github.com/WordPress/gutenberg/pull/72165. + if ( NAVIGATION_BLOCK_TYPES.includes( blockName ) ) { + return false; + } + // Lock editing in query loop. if ( context?.query || context?.queryId ) { return false; @@ -103,8 +158,9 @@ export default { return false; } - const fieldValue = getPostDataFields( select, context )?.[ args.key ] - ?.value; + const fieldValue = getPostDataFields( select, context, undefined )?.[ + args.key + ]?.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; @@ -123,14 +179,19 @@ export default { return true; }, getFieldsList( { select, context } ) { + const clientId = select( blockEditorStore ).getSelectedBlockClientId(); // Deprecated, will be removed after 6.9. - return getPostDataFields( select, context ); + return getPostDataFields( select, context, clientId ); }, editorUI( { select, context } ) { - const selectedBlock = select( 'core/block-editor' ).getSelectedBlock(); + const selectedBlock = select( blockEditorStore ).getSelectedBlock(); if ( selectedBlock?.name !== 'core/post-date' ) { return {}; } + // Exit early for navigation blocks (read-only) + if ( NAVIGATION_BLOCK_TYPES.includes( selectedBlock?.name ) ) { + return {}; + } const postDataFields = Object.entries( getPostDataFields( select, context ) || {} ).map( ( [ key, field ] ) => ( { diff --git a/packages/editor/src/bindings/term-data.js b/packages/editor/src/bindings/term-data.js new file mode 100644 index 00000000000000..937f0480afe570 --- /dev/null +++ b/packages/editor/src/bindings/term-data.js @@ -0,0 +1,224 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { store as coreDataStore } from '@wordpress/core-data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +// Navigation block types that use special handling for backwards compatibility +const NAVIGATION_BLOCK_TYPES = [ + 'core/navigation-link', + '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; +} + +/** + * @type {WPBlockBindingsSource} + */ +export default { + name: 'core/term-data', + usesContext: [ 'taxonomy', 'termId', 'termData' ], + getValues( { select, context, bindings, clientId } ) { + const dataFields = getTermDataFields( select, context, clientId ); + + const newValues = {}; + for ( const [ attributeName, source ] of Object.entries( bindings ) ) { + // Use the value, the field label, or the field key. + const fieldKey = source.args.key; + const { value: fieldValue, label: fieldLabel } = + dataFields?.[ fieldKey ] || {}; + newValues[ attributeName ] = fieldValue ?? fieldLabel ?? fieldKey; + } + return newValues; + }, + // eslint-disable-next-line no-unused-vars + setValues( { dispatch, context, bindings } ) { + // Terms are typically not editable through block bindings in most contexts. + return false; + }, + canUserEditValue( { select, context, args } ) { + const { getBlockName, getSelectedBlockClientId } = + select( blockEditorStore ); + + const clientId = getSelectedBlockClientId(); + const blockName = getBlockName?.( clientId ); + + // Navigaton block types are read-only. + // See https://github.com/WordPress/gutenberg/pull/72165. + if ( NAVIGATION_BLOCK_TYPES.includes( blockName ) ) { + return false; + } + + // Terms are typically read-only when displayed. + if ( context?.termQuery ) { + return false; + } + + // Lock editing when `taxonomy` or `termId` is not defined. + if ( ! context?.taxonomy || ! context?.termId ) { + return false; + } + + const fieldValue = getTermDataFields( select, context, undefined )?.[ + args.key + ]?.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 } ) { + // Deprecated, will be removed after 6.9. + return getTermDataFields( select, context ); + }, + editorUI( { select, context } ) { + const selectedBlock = select( blockEditorStore ).getSelectedBlock(); + // Exit early for navigation blocks (read-only) + if ( NAVIGATION_BLOCK_TYPES.includes( selectedBlock?.name ) ) { + return {}; + } + const termDataFields = Object.entries( + getTermDataFields( select, context ) || {} + ).map( ( [ key, field ] ) => ( { + label: field.label, + type: field.type, + args: { + key, + }, + } ) ); + /* + * We need to define the data as [{ label: string, value: any, type: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#type-validation }] + */ + return { + mode: 'dropdown', + data: termDataFields, + }; + }, +};