Template API: Add after_add_block and after_remove_block actions (#40139)

This commit is contained in:
Matt Sherman 2023-09-15 14:05:09 -04:00 committed by GitHub
commit 6c47589953
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 661 additions and 79 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add after_add_block and after_remove block hooks to the block template API.

View File

@ -62,22 +62,25 @@ interface BlockInterface {
/**
* Get the parent container that the block belongs to.
*
* @throws \RuntimeException If the block does not have a parent.
*/
public function &get_parent(): ?ContainerInterface;
public function &get_parent(): ContainerInterface;
/**
* Get the root template that the block belongs to.
*
* @throws \RuntimeException If the block does not have a root template.
*/
public function &get_root_template(): BlockTemplateInterface;
/**
* Detach the block from its parent and root template.
* Remove the block from its parent.
*/
public function detach();
public function remove();
/**
* Check if the block is detached from its parent or root template.
*
* @return bool True if the block is detached from its parent or root template.
*/
public function is_detached(): bool;
/**
* Get the block configuration as a formatted template.

View File

@ -26,13 +26,6 @@ interface BlockTemplateInterface extends ContainerInterface {
*/
public function get_area(): string;
/**
* Get a block by ID.
*
* @param string $block_id The block ID.
*/
public function get_block( string $block_id ): ?BlockInterface;
/**
* Generate a block ID based on a base.
*

View File

@ -16,6 +16,13 @@ interface ContainerInterface {
*/
public function get_formatted_template(): array;
/**
* Get a block by ID.
*
* @param string $block_id The block ID.
*/
public function get_block( string $block_id ): ?BlockInterface;
/**
* Removes a block from the container.
*

View File

@ -9,6 +9,13 @@ use Automattic\WooCommerce\Internal\Admin\BlockTemplates\AbstractBlockTemplate;
* Block template class.
*/
abstract class AbstractProductFormTemplate extends AbstractBlockTemplate implements ProductFormTemplateInterface {
/**
* Get the template area.
*/
public function get_area(): string {
return 'product-form';
}
/**
* Get a group block by ID.
*

View File

@ -161,38 +161,36 @@ class AbstractBlock implements BlockInterface {
/**
* Get the template that this block belongs to.
*
* @throws \RuntimeException If the block does not have a root template.
*/
public function &get_root_template(): BlockTemplateInterface {
if ( is_null( $this->root_template ) ) {
throw new \RuntimeException( 'The block does not have a root template.' );
}
return $this->root_template;
}
/**
* Get the parent block container.
*
* @throws \RuntimeException If the block does not have a parent.
*/
public function &get_parent(): ContainerInterface {
if ( is_null( $this->parent ) ) {
throw new \RuntimeException( 'The block does not have a parent.' );
}
return $this->parent;
}
/**
* Detach the block from its parent block container and the template it belongs to.
* Remove the block from its parent.
*/
public function detach() {
$this->parent = null;
$this->root_template = null;
public function remove() {
$this->parent->remove_block( $this->id );
}
/**
* Check if the block is detached from its parent block container or the template it belongs to.
*
* @return bool True if the block is detached from its parent block container or the template it belongs to.
*/
public function is_detached(): bool {
$is_in_parent = $this->parent->get_block( $this->id ) === $this;
$is_in_root_template = $this->get_root_template()->get_block( $this->id ) === $this;
return ! ( $is_in_parent && $is_in_root_template );
}
/**
* Get the block configuration as a formatted template.
*

View File

@ -27,19 +27,36 @@ trait BlockContainerTrait {
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If a block with the specified ID already exists in the template.
* @throws \UnexpectedValueException If the block container is not the parent of the block.
* @throws \UnexpectedValueException If the block container's root template is not the same as the block's root template.
*/
protected function &add_inner_block( BlockInterface $block ): BlockInterface {
if ( ! $block instanceof BlockInterface ) {
throw new \UnexpectedValueException( 'The block must return an instance of BlockInterface.' );
}
if ( $block->get_parent() !== $this ) {
throw new \UnexpectedValueException( 'The block container is not the parent of the block.' );
}
$root_template = $block->get_root_template();
$root_template->cache_block( $block );
if ( $block->get_root_template() !== $this->get_root_template() ) {
throw new \UnexpectedValueException( 'The block container\'s root template is not the same as the block\'s root template.' );
}
$is_detached = method_exists( $this, 'is_detached' ) && $this->is_detached();
if ( $is_detached ) {
BlockTemplateLogger::get_instance()->warning(
'Block added to detached container. Block will not be included in the template, since the container will not be included in the template.',
[
'block' => $block,
'container' => $this,
'template' => $this->get_root_template(),
]
);
} else {
$this->get_root_template()->cache_block( $block );
}
$this->inner_blocks[] = &$block;
$this->do_after_add_block_action( $block );
$this->do_after_add_specific_block_action( $block );
return $block;
}
@ -64,6 +81,31 @@ trait BlockContainerTrait {
return $this->is_block_descendant( $parent );
}
/**
* Get a block by ID.
*
* @param string $block_id The block ID.
*/
public function get_block( string $block_id ): ?BlockInterface {
foreach ( $this->inner_blocks as $block ) {
if ( $block->get_id() === $block_id ) {
return $block;
}
}
foreach ( $this->inner_blocks as $block ) {
if ( $block instanceof ContainerInterface ) {
$block = $block->get_block( $block_id );
if ( $block ) {
return $block;
}
}
}
return null;
}
/**
* Remove a block from the block container.
*
@ -89,16 +131,8 @@ trait BlockContainerTrait {
$block->remove_blocks();
}
// Remove block from root template's cache.
$root_template = $this->get_root_template();
$root_template->uncache_block( $block->get_id() );
$parent = $block->get_parent();
$parent->remove_inner_block( $block );
// Detach block from parent and root template.
$block->detach();
}
/**
@ -120,16 +154,29 @@ trait BlockContainerTrait {
* @param BlockInterface $block The block.
*/
public function remove_inner_block( BlockInterface $block ) {
// Remove block from root template's cache.
$root_template = $this->get_root_template();
$root_template->uncache_block( $block->get_id() );
$this->inner_blocks = array_filter(
$this->inner_blocks,
function ( BlockInterface $inner_block ) use ( $block ) {
return $inner_block !== $block;
}
);
BlockTemplateLogger::get_instance()->info(
'Block removed from template.',
[
'block' => $block,
'template' => $root_template,
]
);
$this->do_after_remove_block_action( $block );
$this->do_after_remove_specific_block_action( $block );
}
/**
* Get the inner blocks sorted by order.
*/
@ -168,4 +215,141 @@ trait BlockContainerTrait {
return $arr;
}
/**
* Do the `woocommerce_block_template_after_add_block` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_add_block_action( BlockInterface $block ) {
try {
/**
* Action called after a block is added to a block container.
*
* This action can be used to perform actions after a block is added to the block container,
* such as adding a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( 'woocommerce_block_template_after_add_block', $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after adding block to template.',
'woocommerce_block_template_after_add_block',
$block,
$e
);
}
}
/**
* Do the `woocommerce_block_template_area_{template_area}_after_add_block_{block_id}` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_add_specific_block_action( BlockInterface $block ) {
try {
/**
* Action called after a specific block is added to a template with a specific area.
*
* This action can be used to perform actions after a specific block is added to a template with a specific area,
* such as adding a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_add_block_{$block->get_id()}", $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after adding block to template.',
"woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_add_block_{$block->get_id()}",
$block,
$e
);
}
}
/**
* Do the `woocommerce_block_template_after_remove_block` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_remove_block_action( BlockInterface $block ) {
try {
/**
* Action called after a block is removed from a block container.
*
* This action can be used to perform actions after a block is removed from the block container,
* such as removing a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( 'woocommerce_block_template_after_remove_block', $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after removing block from template.',
'woocommerce_block_template_after_remove_block',
$block,
$e
);
}
}
/**
* Do the `woocommerce_block_template_area_{template_area}_after_remove_block_{block_id}` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_remove_specific_block_action( BlockInterface $block ) {
try {
/**
* Action called after a specific block is removed from a template with a specific area.
*
* This action can be used to perform actions after a specific block is removed from a template with a specific area,
* such as removing a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_remove_block_{$block->get_id()}", $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after removing block from template.',
"woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_remove_block_{$block->get_id()}",
$block,
$e
);
}
}
/**
* Handle an exception thrown by an action.
*
* @param string $message The message.
* @param string $action_tag The action tag.
* @param BlockInterface $block The block.
* @param \Exception $e The exception.
*/
private function handle_exception_doing_action( string $message, string $action_tag, BlockInterface $block, \Exception $e ) {
BlockTemplateLogger::get_instance()->error(
$message,
[
'exception' => $e,
'action' => $action_tag,
'container' => $this,
'block' => $block,
'template' => $this->get_root_template(),
],
);
}
}

View File

@ -0,0 +1,180 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Logger for block template modifications.
*/
class BlockTemplateLogger {
/**
* Singleton instance.
*
* @var BlockTemplateLogger
*/
protected static $instance = null;
/**
* Logger instance.
*
* @var \WC_Logger
*/
protected $logger = null;
/**
* Get the singleton instance.
*/
public static function get_instance(): BlockTemplateLogger {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
protected function __construct() {
$this->logger = wc_get_logger();
}
/**
* Log an informational message.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
public function info( string $message, array $info = [] ) {
$this->logger->info(
$this->format_message( $message, $info ),
[ 'source' => 'block_template' ]
);
}
/**
* Log a warning message.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
public function warning( string $message, array $info = [] ) {
$this->logger->warning(
$this->format_message( $message, $info ),
[ 'source' => 'block_template' ]
);
}
/**
* Log an error message.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
public function error( string $message, array $info = [] ) {
$this->logger->error(
$this->format_message( $message, $info ),
[ 'source' => 'block_template' ]
);
}
/**
* Format a message for logging.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
private function format_message( string $message, array $info = [] ): string {
$formatted_message = sprintf(
"%s\n%s",
$message,
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
print_r( $this->format_info( $info ), true ),
);
return $formatted_message;
}
/**
* Format info for logging.
*
* @param array $info Info to log.
*/
private function format_info( array $info ): array {
$formatted_info = $info;
if ( isset( $info['exception'] ) && $info['exception'] instanceof \Exception ) {
$formatted_info['exception'] = $this->format_exception( $info['exception'] );
}
if ( isset( $info['container'] ) ) {
if ( $info['container'] instanceof BlockContainerInterface ) {
$formatted_info['container'] = $this->format_block( $info['container'] );
} elseif ( $info['container'] instanceof BlockTemplateInterface ) {
$formatted_info['container'] = $this->format_template( $info['container'] );
} elseif ( $info['container'] instanceof BlockInterface ) {
$formatted_info['container'] = $this->format_block( $info['container'] );
}
}
if ( isset( $info['block'] ) && $info['block'] instanceof BlockInterface ) {
$formatted_info['block'] = $this->format_block( $info['block'] );
}
if ( isset( $info['template'] ) && $info['template'] instanceof BlockTemplateInterface ) {
$formatted_info['template'] = $this->format_template( $info['template'] );
}
return $formatted_info;
}
/**
* Format an exception for logging.
*
* @param \Exception $exception Exception to format.
*/
private function format_exception( \Exception $exception ): array {
return [
'message' => $exception->getMessage(),
'source' => "{$exception->getFile()}: {$exception->getLine()}",
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
'trace' => print_r( $this->format_exception_trace( $exception->getTrace() ), true ),
];
}
/**
* Format an exception trace for logging.
*
* @param array $trace Exception trace to format.
*/
private function format_exception_trace( array $trace ): array {
$formatted_trace = [];
foreach ( $trace as $source ) {
$formatted_trace[] = "{$source['file']}: {$source['line']}";
}
return $formatted_trace;
}
/**
* Format a block template for logging.
*
* @param BlockTemplateInterface $template Template to format.
*/
private function format_template( BlockTemplateInterface $template ): string {
return "{$template->get_id()} (area: {$template->get_area()})";
}
/**
* Format a block for logging.
*
* @param BlockInterface $block Block to format.
*/
private function format_block( BlockInterface $block ): string {
return "{$block->get_id()} (name: {$block->get_name()})";
}
}

View File

@ -2,6 +2,7 @@
namespace Automattic\WooCommerce\Tests\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Internal\Admin\BlockTemplates\BlockTemplate;
use WC_Unit_Test_Case;
@ -35,6 +36,140 @@ class BlockTemplateTest extends WC_Unit_Test_Case {
$this->assertSame( $block, $template->get_block( 'test-block-id' ) );
}
/**
* Test the after_add_block hooks fires.
*/
public function test_after_add_block_hooks() {
$template = new BlockTemplate();
$hook_called = false;
$after_add_block_hook = function( BlockInterface $block ) use ( &$hook_called ) {
$hook_called = true;
if ( 'test-block-id' === $block->get_id() ) {
$hook_called = true;
}
$root_template = $block->get_root_template();
if ( $root_template->get_block( 'test-block-id-2' ) ) {
// The block was already added, so just return.
// This short-circuiting done because this hook will be called two times:
// 1. When the `test-block-id` block is added to the root template.
// 2. When the `test-block-id-2` block is added to the template in this hook.
// Without this short-circuiting, the second time `add_block` is called in this
// hook would throw an exception, which is handled by the API (an error gets logged).
return;
}
$root_template->add_block(
[
'id' => 'test-block-id-2',
'blockName' => 'test-block-name-2',
]
);
};
try {
add_action( 'woocommerce_block_template_after_add_block', $after_add_block_hook );
$specific_hook_called = false;
$specific_after_add_block_hook = function( BlockInterface $block ) use ( &$specific_hook_called ) {
if ( 'test-block-id' === $block->get_id() ) {
$specific_hook_called = true;
}
};
add_action( 'woocommerce_block_template_area_uncategorized_after_add_block_test-block-id', $specific_after_add_block_hook );
$template->add_block(
[
'id' => 'test-block-id',
'blockName' => 'test-block-name',
]
);
$this->assertTrue(
$hook_called,
'Failed asserting that that the hook was called.'
);
$this->assertTrue(
$specific_hook_called,
'Failed asserting that that the specific hook was called.'
);
$this->assertNotNull(
$template->get_block( 'test-block-id-2' ),
'Failed asserting that the block was added to the template from the hook.'
);
} finally {
remove_action( 'woocommerce_block_template_after_add_block', $after_add_block_hook );
remove_action( 'woocommerce_block_template_area_uncategorized_after_add_block_test-block-id', $specific_after_add_block_hook );
}
}
/**
* Test the after_remove_block hooks fires.
*/
public function test_after_remove_block_hooks() {
$template = new BlockTemplate();
$hook_called = false;
$after_remove_block_hook = function( BlockInterface $block ) use ( &$hook_called ) {
$hook_called = true;
if ( 'test-block-id' === $block->get_id() ) {
$hook_called = true;
}
$root_template = $block->get_root_template();
};
$specific_hook_called = false;
$specific_after_remove_block_hook = function( BlockInterface $block ) use ( &$specific_hook_called ) {
if ( 'test-block-id' === $block->get_id() ) {
$specific_hook_called = true;
}
};
try {
add_action( 'woocommerce_block_template_after_remove_block', $after_remove_block_hook );
add_action( 'woocommerce_block_template_area_uncategorized_after_remove_block_test-block-id', $specific_after_remove_block_hook );
$block = $template->add_block(
[
'id' => 'test-block-id',
'blockName' => 'test-block-name',
]
);
$block->remove();
$this->assertTrue(
$hook_called,
'Failed asserting that that the hook was called.'
);
$this->assertTrue(
$specific_hook_called,
'Failed asserting that that the specific hook was called.'
);
$this->assertTrue(
$block->is_detached(),
'Failed asserting that the block was added to the template from the hook.'
);
} finally {
remove_action( 'woocommerce_block_template_after_remove_block', $after_remove_block_hook );
remove_action( 'woocommerce_block_template_area_uncategorized_after_remove_block_test-block-id', $specific_after_remove_block_hook );
}
}
/**
* Test adding a block throws an exception if a block with the same ID already exists.
*/

View File

@ -106,7 +106,7 @@ class BlockTest extends WC_Unit_Test_Case {
}
/**
* Test that removing a block from a block sets the parent and root template to null
* Test that removing a block from a block detaches it
* and that the block is removed from the root template.
*/
public function test_remove_block() {
@ -133,13 +133,19 @@ class BlockTest extends WC_Unit_Test_Case {
'Failed asserting that the child block was removed from the root template.'
);
$this->expectException( \RuntimeException::class );
$this->assertNull(
$block->get_block( 'test-block-id-2' ),
'Failed asserting that the child block was removed from the parent.'
);
$child_block->get_parent();
$this->assertTrue(
$child_block->is_detached(),
'Failed asserting that the child block is detached from its parent and root template.'
);
}
/**
* Test that removing a block from a block sets the parent and root template to null
* Test that removing a block from a block detaches it
* and that the block is removed from the root template, as well as any descendants.
*/
public function test_remove_nested_block() {
@ -166,13 +172,19 @@ class BlockTest extends WC_Unit_Test_Case {
'Failed asserting that the nested descendent block was removed from the root template.'
);
$this->expectException( \RuntimeException::class );
$this->assertNull(
$block->get_block( 'test-block-id-2' ),
'Failed asserting that the nested descendent block was removed from the parent.'
);
$child_block->get_parent();
$this->assertTrue(
$child_block->is_detached(),
'Failed asserting that the nested descendent block is detached from its parent and root template.'
);
}
/**
* Test that removing a block from a block sets the parent and root template to null
* Test that removing a block from a block detaches it
* and that the block is removed from the root template, as well as any descendants.
*/
public function test_remove_block_and_descendants() {
@ -204,9 +216,41 @@ class BlockTest extends WC_Unit_Test_Case {
'Failed asserting that the nested descendent block was removed from the root template.'
);
$this->expectException( \RuntimeException::class );
$this->assertNull(
$block->get_block( 'test-block-id-2' ),
'Failed asserting that the child block was removed from the parent.'
);
$child_block->get_parent();
$this->assertTrue(
$block->is_detached(),
'Failed asserting that the block is detached from its parent and root template.'
);
$this->assertTrue(
$child_block->is_detached(),
'Failed asserting that the child block is detached from its parent and root template.'
);
}
/**
* Test that removing a block by calling remove on it detaches it.
*/
public function test_remove_block_self() {
$template = new BlockTemplate();
$block = $template->add_block(
[
'id' => 'test-block-id',
'blockName' => 'test-block-name',
]
);
$block->remove();
$this->assertTrue(
$block->is_detached(),
'Failed asserting that the block is detached from its parent and root template.'
);
}
/**
@ -266,6 +310,49 @@ class BlockTest extends WC_Unit_Test_Case {
);
}
/**
* Test that a block added to a detached block is detached.
*/
public function test_block_added_to_detached_block_is_detached() {
$template = new BlockTemplate();
$block = $template->add_block(
[
'id' => 'test-block-id',
'blockName' => 'test-block-name',
]
);
$template->remove_block( 'test-block-id' );
$child_block = $block->add_block(
[
'id' => 'test-block-id-2',
'blockName' => 'test-block-name-2',
]
);
$this->assertNull(
$template->get_block( 'test-block-id' ),
'Failed asserting that the block was removed from the root template.'
);
$this->assertNull(
$template->get_block( 'test-block-id-2' ),
'Failed asserting that the nested block is not in the root template.'
);
$this->assertNotNull(
$block->get_block( 'test-block-id-2' ),
'Failed asserting that the nested block is in the parent.'
);
$this->assertTrue(
$child_block->is_detached(),
'Failed asserting that the nested descendent block is detached from its parent and root template.'
);
}
/**
* Test that getting the block as a formatted template is structured correctly.
*/

View File

@ -42,7 +42,7 @@ class CustomBlockTemplate extends AbstractBlockTemplate {
*
* @param array $block_config The block data.
*/
public function add_custom_block( array $block_config ): BlockInterface {
public function add_custom_block( array $block_config ): CustomBlockInterface {
$block = new CustomBlock( $block_config, $this->get_root_template(), $this );
return $this->add_inner_block( $block );
}

View File

@ -7,7 +7,7 @@ use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Internal\Admin\BlockTemplates\Block;
use Automattic\WooCommerce\Internal\Admin\BlockTemplates\BlockTemplate;
use Customers;
use WC_Unit_Test_Case;
/**
@ -18,13 +18,8 @@ class CustomBlockTest extends WC_Unit_Test_Case {
* Test that the add_block method does not exist by default on blocks.
*/
public function test_add_block_does_not_exist() {
$template = new BlockTemplate();
$block = new CustomBlock(
[
'blockName' => 'test-block-name',
],
$template
);
$template = new CustomBlockTemplate();
$block = $template->add_custom_block( [ 'blockName' => 'test-block-name' ] );
$this->assertFalse( method_exists( $block, 'add_block' ) );
}
@ -33,13 +28,8 @@ class CustomBlockTest extends WC_Unit_Test_Case {
* Test that a custom block inserter method inserts as expected.
*/
public function test_add_custom_inner_block() {
$template = new BlockTemplate();
$block = new CustomBlock(
[
'blockName' => 'test-block-name',
],
$template
);
$template = new CustomBlockTemplate();
$block = $template->add_custom_block( [ 'blockName' => 'test-block-name' ] );
$block->add_custom_inner_block( 'a' );
$block->add_custom_inner_block( 'b' );
@ -72,14 +62,8 @@ class CustomBlockTest extends WC_Unit_Test_Case {
* Test that a custom block is removed as expected.
*/
public function test_remove_custom_inner_block() {
$template = new BlockTemplate();
$block = new CustomBlock(
[
'blockName' => 'test-block-name',
],
$template
);
$template = new CustomBlockTemplate();
$block = $template->add_custom_block( [ 'blockName' => 'test-block-name' ] );
$block->add_custom_inner_block( 'a' );
$block->add_custom_inner_block( 'b' );