diff --git a/packages/e2e-tests/plugins/block-hooks.php b/packages/e2e-tests/plugins/block-hooks.php new file mode 100644 index 0000000000000..3490595477055 --- /dev/null +++ b/packages/e2e-tests/plugins/block-hooks.php @@ -0,0 +1,74 @@ + "hooked-block-{$relative_position}-" . str_replace( 'core/', '', $anchor_block['blockName'] ), + ); + $hooked_block['innerContent'] = array( + sprintf( + '

This block was inserted by the Block Hooks API in the %2$s position next to the %3$s anchor block.

', + $hooked_block['attrs']['className'], + $relative_position, + $anchor_block['blockName'] + ), + ); + } + + return $hooked_block; +} +add_filter( 'hooked_block_core/paragraph', 'gutenberg_test_set_hooked_block_inner_html', 10, 4 ); + +function gutenberg_register_wp_ignored_hooked_blocks_meta() { + register_post_meta( + 'post', + '_wp_ignored_hooked_blocks', + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + 'auth_callback' => function () { + return current_user_can( 'edit_posts' ); + }, + ) + ); +} +add_action( 'rest_api_init', 'gutenberg_register_wp_ignored_hooked_blocks_meta' ); diff --git a/test/e2e/specs/editor/plugins/block-hooks.spec.js b/test/e2e/specs/editor/plugins/block-hooks.spec.js new file mode 100644 index 0000000000000..5289f2a2d86bb --- /dev/null +++ b/test/e2e/specs/editor/plugins/block-hooks.spec.js @@ -0,0 +1,292 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const dummyBlockContent = ` +

This is a dummy heading

+ + +

This is a dummy paragraph.

+`; + +const getHookedBlockClassName = ( relativePosition, anchorBlock ) => + `hooked-block-${ relativePosition }-${ anchorBlock.replace( + 'core/', + '' + ) }`; + +const getHookedBlockContent = ( relativePosition, anchorBlock ) => + `This block was inserted by the Block Hooks API in the ${ relativePosition } position next to the ${ anchorBlock } anchor block.`; + +test.describe( 'Block Hooks API', () => { + [ + { + name: 'Post Content', + postType: 'post', + blockType: 'core/post-content', + createMethod: 'createPost', + }, + { + name: 'Synced Pattern', + postType: 'wp_block', + blockType: 'core/block', + createMethod: 'createBlock', + }, + ].forEach( ( { name, postType, blockType, createMethod } ) => { + test.describe( `Hooked blocks in ${ name }`, () => { + let postObject, containerPost; + test.beforeAll( async ( { requestUtils } ) => { + postObject = await requestUtils[ createMethod ]( { + title: name, + status: 'publish', + content: dummyBlockContent, + } ); + + await requestUtils.activatePlugin( + 'gutenberg-test-block-hooks' + ); + + if ( postType !== 'post' ) { + // We need a container post to hold our block instance. + containerPost = await requestUtils.createPost( { + title: `Block Hooks in ${ name }`, + status: 'publish', + content: ``, + meta: { + // Prevent Block Hooks from injecting blocks into the container + // post content so they won't distract from the ones injected + // into the block instance. + _wp_ignored_hooked_blocks: '["core/paragraph"]', + }, + } ); + } else { + containerPost = postObject; + } + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( + 'gutenberg-test-block-hooks' + ); + + await requestUtils.deleteAllPosts(); + await requestUtils.deleteAllBlocks(); + } ); + + test( `should insert hooked blocks into ${ name } on frontend`, async ( { + page, + } ) => { + await page.goto( `/?p=${ containerPost.id }` ); + await expect( + page.locator( '.entry-content > *' ) + ).toHaveClass( [ + 'wp-block-heading', + getHookedBlockClassName( 'after', 'core/heading' ), + 'dummy-paragraph', + getHookedBlockClassName( 'last_child', blockType ), + ] ); + } ); + + test( `should insert hooked blocks into ${ name } in editor and respect changes made there`, async ( { + admin, + editor, + page, + } ) => { + const expectedHookedBlockAfterHeading = { + name: 'core/paragraph', + attributes: { + className: getHookedBlockClassName( + 'after', + 'core/heading' + ), + }, + }; + + const expectedHookedBlockLastChild = { + name: 'core/paragraph', + attributes: { + className: getHookedBlockClassName( + 'last_child', + blockType + ), + }, + }; + + await admin.editPost( postObject.id ); + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ + { name: 'core/heading' }, + expectedHookedBlockAfterHeading, + { name: 'core/paragraph' }, + expectedHookedBlockLastChild, + ] ); + + const hookedBlock = editor.canvas.getByText( + getHookedBlockContent( 'last_child', blockType ) + ); + await editor.selectBlocks( hookedBlock ); + await editor.clickBlockToolbarButton( 'Move up' ); + + // Save updated post. + const saveButton = page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save', exact: true } ); + await saveButton.click(); + await page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'updated' } ) + .waitFor(); + + // Reload and verify that the new position of the hooked block has been persisted. + await page.reload(); + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ + { name: 'core/heading' }, + expectedHookedBlockAfterHeading, + expectedHookedBlockLastChild, + { name: 'core/paragraph' }, + ] ); + + // Verify that the frontend reflects the changes made in the editor. + await page.goto( `/?p=${ containerPost.id }` ); + await expect( + page.locator( '.entry-content > *' ) + ).toHaveClass( [ + 'wp-block-heading', + getHookedBlockClassName( 'after', 'core/heading' ), + getHookedBlockClassName( 'last_child', blockType ), + 'dummy-paragraph', + ] ); + } ); + } ); + } ); + + test.describe( `Hooked blocks in Navigation Menu`, () => { + let postObject, containerPost; + test.beforeAll( async ( { requestUtils } ) => { + postObject = await requestUtils.createNavigationMenu( { + title: 'Navigation Menu', + status: 'publish', + content: + '', + } ); + + await requestUtils.activatePlugin( 'gutenberg-test-block-hooks' ); + + // We need a container post to hold our block instance. + containerPost = await requestUtils.createPost( { + title: `Block Hooks in Navigation Menu`, + status: 'publish', + content: ``, + meta: { + // Prevent Block Hooks from injecting blocks into the container + // post content so they won't distract from the ones injected + // into the block instance. + _wp_ignored_hooked_blocks: '["core/paragraph"]', + }, + } ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( 'gutenberg-test-block-hooks' ); + + await requestUtils.deleteAllPosts(); + await requestUtils.deleteAllBlocks(); + } ); + + test( `should insert hooked blocks into Navigation Menu on frontend`, async ( { + page, + } ) => { + await page.goto( `/?p=${ containerPost.id }` ); + await expect( + page.locator( '.wp-block-navigation__container > *' ) + ).toHaveClass( [ + 'wp-block-navigation-item wp-block-home-link', + ' wp-block-navigation-item wp-block-navigation-link', + 'wp-block-page-list', + ] ); + } ); + + test( `should insert hooked blocks into Navigation Menu in editor and respect changes made there`, async ( { + admin, + editor, + page, + } ) => { + await admin.visitSiteEditor( { + postId: postObject.id, + postType: 'wp_navigation', + canvas: 'edit', + } ); + + // Since the Navigation block is a controlled block, we need + // to specify its client ID when calling `getBlocks`. + let navigationBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Navigation', + } ); + let navigationClientId = + await navigationBlock.getAttribute( 'data-block' ); + + await expect + .poll( () => + editor.getBlocks( { + clientId: navigationClientId, + } ) + ) + .toMatchObject( [ + { name: 'core/home-link' }, + { name: 'core/navigation-link' }, + { name: 'core/page-list' }, + ] ); + + const hookedBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Home Link', + } ); + await editor.selectBlocks( hookedBlock ); + await editor.clickBlockToolbarButton( 'Move right' ); + + // Save updated post. + const saveButton = page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save', exact: true } ); + await saveButton.click(); + await page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'updated' } ) + .waitFor(); + + // Reload and verify that the new position of the hooked block has been persisted. + await page.reload(); + + navigationBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Navigation', + } ); + navigationClientId = + await navigationBlock.getAttribute( 'data-block' ); + + await expect + .poll( () => + editor.getBlocks( { + clientId: navigationClientId, + } ) + ) + .toMatchObject( [ + { name: 'core/navigation-link' }, + { name: 'core/home-link' }, + { name: 'core/page-list' }, + ] ); + + // Verify that the frontend reflects the changes made in the editor. + await page.goto( `/?p=${ containerPost.id }` ); + await expect( + page.locator( '.wp-block-navigation__container > *' ) + ).toHaveClass( [ + ' wp-block-navigation-item wp-block-navigation-link', + 'wp-block-navigation-item wp-block-home-link', + 'wp-block-page-list', + ] ); + } ); + } ); +} );