diff --git a/lib/experimental/block-bindings/sources/pattern.php b/lib/experimental/block-bindings/sources/pattern.php index 16bf46f4f06ab6..740d1983d6fe80 100644 --- a/lib/experimental/block-bindings/sources/pattern.php +++ b/lib/experimental/block-bindings/sources/pattern.php @@ -9,8 +9,23 @@ if ( ! _wp_array_get( $block_instance->attributes, array( 'metadata', 'id' ), false ) ) { return null; } - $block_id = $block_instance->attributes['metadata']['id']; - return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id, $attribute_name ), null ); + $block_id = $block_instance->attributes['metadata']['id']; + $attribute_override = _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id, $attribute_name ), null ); + if ( null === $attribute_override ) { + return null; + } + switch ( $attribute_override[0] ) { + case 0: // remove + /** + * TODO: This currently doesn't remove the attribute, but only set it to an empty string. + * It's a temporary solution until the block binding API supports different operations. + */ + return ''; + case 1: // replace + return $attribute_override[1]; + default: + return null; + } }; wp_block_bindings_register_source( 'pattern_attributes', diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index 4c22601fa91023..39ac86970367ce 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -101,7 +101,7 @@ function gutenberg_process_block_bindings( $block_content, $block, $block_instan 'core/paragraph' => array( 'content' ), 'core/heading' => array( 'content' ), 'core/image' => array( 'url', 'title', 'alt' ), - 'core/button' => array( 'url', 'text' ), + 'core/button' => array( 'url', 'text', 'linkTarget' ), ); // If the block doesn't have the bindings property or isn't one of the allowed block types, return. diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index cd04e09605ff40..2d517983c3fe12 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -88,6 +88,26 @@ const useInferredLayout = ( blocks, parentLayout ) => { }, [ blocks, parentLayout ] ); }; +/** + * Enum for patch operations. + * We use integers here to minimize the size of the serialized data. + * This has to be deserialized accordingly on the server side. + * See block-bindings/sources/pattern.php + */ +const PATCH_OPERATIONS = { + /** @type {0} */ + Remove: 0, + /** @type {1} */ + Replace: 1, + // Other operations are reserved for future use. (e.g. Add) +}; + +/** + * @typedef {[typeof PATCH_OPERATIONS.Remove]} RemovePatch + * @typedef {[typeof PATCH_OPERATIONS.Replace, unknown]} ReplacePatch + * @typedef {RemovePatch | ReplacePatch} OverridePatch + */ + function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { return blocks.map( ( block ) => { const innerBlocks = applyInitialOverrides( @@ -104,9 +124,15 @@ function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { defaultValues[ blockId ] ??= {}; defaultValues[ blockId ][ attributeKey ] = block.attributes[ attributeKey ]; - if ( overrides[ blockId ]?.[ attributeKey ] !== undefined ) { - newAttributes[ attributeKey ] = - overrides[ blockId ][ attributeKey ]; + /** @type {OverridePatch} */ + const overrideAttribute = overrides[ blockId ]?.[ attributeKey ]; + if ( ! overrideAttribute ) { + continue; + } + if ( overrideAttribute[ 0 ] === PATCH_OPERATIONS.Remove ) { + delete newAttributes[ attributeKey ]; + } else if ( overrideAttribute[ 0 ] === PATCH_OPERATIONS.Replace ) { + newAttributes[ attributeKey ] = overrideAttribute[ 1 ]; } } return { @@ -118,13 +144,14 @@ function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { } function getOverridesFromBlocks( blocks, defaultValues ) { - /** @type {Record>} */ + /** @type {Record>} */ const overrides = {}; for ( const block of blocks ) { Object.assign( overrides, getOverridesFromBlocks( block.innerBlocks, defaultValues ) ); + /** @type {string} */ const blockId = block.attributes.metadata?.id; if ( ! isPartiallySynced( block ) || ! blockId ) continue; const attributes = getPartiallySyncedAttributes( block ); @@ -134,10 +161,23 @@ function getOverridesFromBlocks( blocks, defaultValues ) { defaultValues[ blockId ][ attributeKey ] ) { overrides[ blockId ] ??= {}; - // TODO: We need a way to represent `undefined` in the serialized overrides. - // Also see: https://github.com/WordPress/gutenberg/pull/57249#discussion_r1452987871 - overrides[ blockId ][ attributeKey ] = - block.attributes[ attributeKey ]; + /** + * Create a patch operation for the binding attribute. + * We use a tuple here to minimize the size of the serialized data. + * The first item is the operation type, the second item is the value if any. + */ + if ( block.attributes[ attributeKey ] === undefined ) { + /** @type {RemovePatch} */ + overrides[ blockId ][ attributeKey ] = [ + PATCH_OPERATIONS.Remove, + ]; + } else { + /** @type {ReplacePatch} */ + overrides[ blockId ][ attributeKey ] = [ + PATCH_OPERATIONS.Replace, + block.attributes[ attributeKey ], + ]; + } } } } diff --git a/packages/patterns/src/constants.js b/packages/patterns/src/constants.js index 5fa9ec44f4e3aa..9ce2aff2fc46d4 100644 --- a/packages/patterns/src/constants.js +++ b/packages/patterns/src/constants.js @@ -27,6 +27,7 @@ export const PARTIAL_SYNCING_SUPPORTED_BLOCKS = { 'core/button': { text: __( 'Text' ), url: __( 'URL' ), + linkTarget: __( 'Link Target' ), }, 'core/image': { url: __( 'URL' ), diff --git a/test/e2e/specs/editor/various/pattern-overrides.spec.js b/test/e2e/specs/editor/various/pattern-overrides.spec.js index 4e7bc80fdff5d6..56c6b2a7c15f46 100644 --- a/test/e2e/specs/editor/various/pattern-overrides.spec.js +++ b/test/e2e/specs/editor/various/pattern-overrides.spec.js @@ -176,7 +176,7 @@ test.describe( 'Pattern Overrides', () => { ref: patternId, overrides: { [ editableParagraphId ]: { - content: 'I would word it this way', + content: [ 1, 'I would word it this way' ], }, }, }, @@ -187,7 +187,7 @@ test.describe( 'Pattern Overrides', () => { ref: patternId, overrides: { [ editableParagraphId ]: { - content: 'This one is different', + content: [ 1, 'This one is different' ], }, }, }, @@ -263,4 +263,66 @@ test.describe( 'Pattern Overrides', () => { }, ] ); } ); + + test( 'Supports `undefined` attribute values in patterns', async ( { + page, + admin, + editor, + requestUtils, + } ) => { + const buttonId = 'button-id'; + const { id } = await requestUtils.createBlock( { + title: 'Pattern with overrides', + content: ` +
+ +
+`, + status: 'publish', + } ); + + await admin.createNewPost(); + + await editor.insertBlock( { + name: 'core/block', + attributes: { ref: id }, + } ); + + await editor.canvas + .getByRole( 'document', { name: 'Block: Button' } ) + .getByRole( 'textbox', { name: 'Button text' } ) + .focus(); + + await expect( + page.getByRole( 'link', { name: 'wp.org' } ) + ).toContainText( 'opens in a new tab' ); + + const openInNewTabCheckbox = page.getByRole( 'checkbox', { + name: 'Open in new tab', + } ); + await expect( openInNewTabCheckbox ).toBeChecked(); + + await openInNewTabCheckbox.setChecked( false ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/block', + attributes: { + ref: id, + overrides: { + [ buttonId ]: { + linkTarget: [ 0 ], + }, + }, + }, + }, + ] ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + const link = page.getByRole( 'link', { name: 'wp.org' } ); + await expect( link ).toHaveAttribute( 'href', 'http://wp.org' ); + await expect( link ).toHaveAttribute( 'target', '' ); + } ); } );