ProductDetails.php
<?php declare( strict_types = 1 );
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use WP_Block;
use WP_HTML_Tag_Processor;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductDetails class.
*/
class ProductDetails extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-details';
/**
* Initialize the block type.
*
* @return void
*/
protected function initialize() {
parent::initialize();
/**
* Filter the blocks that are hooked into the Product Details block.
*
* @hook woocommerce_product_details_hooked_blocks
*
* @since 10.0.0
* @param {array} $hooked_blocks The blocks that are hooked into the Product Details block.
* @return {array} The blocks that are hooked into the Product Details block.
*/
$hooked_blocks = apply_filters( 'woocommerce_product_details_hooked_blocks', [] );
foreach ( $this->validate_hooked_blocks( $hooked_blocks ) as $slug => $block ) {
$this->register_hooked_block( $slug, $block );
}
}
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string|null
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
if ( empty( $block->parsed_block['innerBlocks'] ) ) {
return $this->render_legacy_block( $attributes, $content, $block );
}
$parsed_block = $block->parsed_block;
$parsed_block = $this->hide_empty_accordion_items( $parsed_block, $block->context );
/**
* Filter to disable the compatibility layer for the blockified templates.
*
* @see AddToCartWithOptions::render() for full documentation.
* @since 7.6.0
*/
if ( ! apply_filters( 'woocommerce_disable_compatibility_layer', false ) ) {
$parsed_block = $this->inject_compatible_tabs( $parsed_block );
}
$inner_content = array_reduce(
$parsed_block['innerBlocks'],
function ( $carry, $parsed_inner_block ) use ( $block ) {
$carry .= ( new \WP_Block( $parsed_inner_block, $block->context ) )->render();
return $carry;
},
''
);
return sprintf(
'<div %1$s>%2$s</div>',
get_block_wrapper_attributes(),
$inner_content
);
}
/**
* Inject compatible tabs.
*
* @param array $parsed_block Parsed block.
*
* @return array Parsed block.
*/
private function inject_compatible_tabs( $parsed_block ) {
if ( ! $this->has_accordion( $parsed_block ) ) {
return $parsed_block;
}
/**
* Filter the product tabs in the product details block.
*
* @since 3.3.0
* @param array $tabs Array of product tabs.
*/
$product_tabs = apply_filters(
'woocommerce_product_tabs',
array()
);
$default_tabs_callbacks = array(
'woocommerce_product_description_tab',
'woocommerce_product_additional_information_tab',
'comments_template',
);
$product_tabs = array_filter(
$product_tabs,
function ( $tab ) use ( $default_tabs_callbacks ) {
return ! in_array( $tab['callback'], $default_tabs_callbacks, true );
}
);
usort(
$product_tabs,
function ( $a, $b ) {
return $a['priority'] <=> $b['priority'];
}
);
$accordion_blocks = array();
foreach ( $product_tabs as $key => $tab ) {
ob_start();
call_user_func( $tab['callback'], $key, $tab );
$tab_content = ob_get_clean();
$accordion_blocks[] = $this->create_accordion_item_block(
$tab['title'],
'<!-- wp:html -->' . $tab_content . '<!-- /wp:html -->'
);
}
return $this->inject_parsed_accordion_blocks( $parsed_block, $accordion_blocks );
}
/**
* Create an accordion item block.
*
* @param string $title Title of the accordion item.
* @param string $content Content of the accordion item as block markup.
*
* @return array Accordion item.
*/
private function create_accordion_item_block( $title, $content ) {
$template = '<!-- wp:woocommerce/accordion-item -->
<div class="wp-block-woocommerce-accordion-item"><!-- wp:woocommerce/accordion-header -->
<h3 class="wp-block-woocommerce-accordion-header accordion-item__heading">
<button class="accordion-item__toggle">
<span>%1$s</span>
<span class="accordion-item__toggle-icon has-icon-plus" style="width:1.2em;height:1.2em"><svg width="1.2em" height="1.2em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M11 12.5V17.5H12.5V12.5H17.5V11H12.5V6H11V11H6V12.5H11Z" fill="currentColor"></path></svg></span>
</button>
</h3>
<!-- /wp:woocommerce/accordion-header -->
<!-- wp:woocommerce/accordion-panel -->
<div class="wp-block-woocommerce-accordion-panel"><div class="accordion-content__wrapper">
%2$s
</div></div>
<!-- /wp:woocommerce/accordion-panel --></div>
<!-- /wp:woocommerce/accordion-item -->';
return parse_blocks( sprintf( $template, $title, $content ) )[0];
}
/**
* Inject parsed accordion blocks.
*
* @param array $parsed_block Parsed block.
* @param array $accordion_blocks Accordion blocks.
*
* @return array Parsed block.
*/
private function inject_parsed_accordion_blocks( $parsed_block, $accordion_blocks ) {
if ( 'woocommerce/accordion-group' === $parsed_block['blockName'] ) {
$parsed_block['innerBlocks'] = array_merge( $parsed_block['innerBlocks'], $accordion_blocks );
$parsed_block['innerBlocks'] = array_values( array_filter( $parsed_block['innerBlocks'] ) );
$opening_tag = reset( $parsed_block['innerContent'] );
$closing_tag = end( $parsed_block['innerContent'] );
$parsed_block['innerContent'] = array_merge(
array( $opening_tag ),
array_fill( 0, count( $parsed_block['innerBlocks'] ), null ),
array( $closing_tag )
);
return $parsed_block;
}
foreach ( $parsed_block['innerBlocks'] as $key => $inner_block ) {
$parsed_block['innerBlocks'][ $key ] = $this->inject_parsed_accordion_blocks( $inner_block, $accordion_blocks );
}
return $parsed_block;
}
/**
* Hide empty accordion items.
*
* @param array $parsed_block Parsed block.
* @param array $context Context.
*
* @return array Parsed block.
*/
private function hide_empty_accordion_items( $parsed_block, $context ) {
if ( ! $this->has_accordion( $parsed_block ) ) {
return $parsed_block;
}
if ( 'woocommerce/accordion-group' === $parsed_block['blockName'] ) {
foreach ( $parsed_block['innerBlocks'] as $key => $inner_block ) {
$parsed_block['innerBlocks'][ $key ] = $this->mark_accordion_item_hidden( $inner_block, $context );
}
$parsed_block['innerBlocks'] = array_values( array_filter( $parsed_block['innerBlocks'] ) );
$opening_tag = reset( $parsed_block['innerContent'] );
$closing_tag = end( $parsed_block['innerContent'] );
$parsed_block['innerContent'] = array_merge(
array( $opening_tag ),
array_fill( 0, count( $parsed_block['innerBlocks'] ), null ),
array( $closing_tag )
);
return $parsed_block;
}
foreach ( $parsed_block['innerBlocks'] as $key => $inner_block ) {
$parsed_block['innerBlocks'][ $key ] = $this->hide_empty_accordion_items( $inner_block, $context );
}
return $parsed_block;
}
/**
* Mark an accordion item as hidden if it has no content.
*
* @param array $item Item to mark.
* @param array $context Context.
*
* @return array Item.
*/
private function mark_accordion_item_hidden( $item, $context ) {
$content_block = end( $item['innerBlocks'] );
$rendered_content_block = ( new WP_Block( $content_block, $context ) )->render();
$p = new WP_HTML_Tag_Processor( $rendered_content_block );
$has_content = $p->next_tag( 'img' ) ||
$p->next_tag( 'iframe' ) ||
$p->next_tag( 'video' ) ||
$p->next_tag( 'meter' ) ||
! empty( wp_strip_all_tags( $rendered_content_block, true ) );
if ( ! $has_content ) {
return array();
}
return $item;
}
/**
* Check if a parsed block has an accordion.
*
* @param array $parsed_block Parsed block.
*
* @return bool True if the block has an accordion, false otherwise.
*/
private function has_accordion( $parsed_block ) {
if ( 'woocommerce/accordion-group' === $parsed_block['blockName'] ) {
return true;
}
foreach ( $parsed_block['innerBlocks'] as $inner_block ) {
if ( $this->has_accordion( $inner_block ) ) {
return true;
}
}
return false;
}
/**
* Validate hooked blocks data. Remove duplicated entries with the same title
* and invalid entries with invalid content. Log errors to the WC logger.
*
* @param array $hooked_blocks { Hooked blocks data.
* @type string $title Title of the hooked block.
* @type string $content Content of the hooked block, as block markup.
* }
*
* @return array Validated hooked blocks.
*/
private function validate_hooked_blocks( $hooked_blocks ) {
$logger = wc_get_logger();
$validated_hooked_blocks = [];
foreach ( $hooked_blocks as $block ) {
$invalid = ! is_array( $block ) ||
! isset( $block['title'] ) ||
! isset( $block['content'] ) ||
! is_string( $block['title'] ) ||
! is_string( $block['content'] );
if ( ! $invalid ) {
$parsed_content = parse_blocks( $block['content'] );
foreach ( $parsed_content as $content_block ) {
if ( ! isset( $content_block['blockName'] ) ) {
$invalid = true;
break;
}
}
}
if ( $invalid ) {
$logger->error( 'Invalid hooked block data. Expected array with `title` and `content` keys with string values. Content must be valid block markup.', $block );
continue;
}
$slug = sanitize_title( $block['title'] );
/**
* If the block is already registered, replace the block. We use the
* last registered block for the same slug. This makes overriding
* hooked block easier.
*/
if ( isset( $validated_hooked_blocks[ $slug ] ) ) {
$validated_hooked_blocks[ $slug ] = $block;
continue;
}
$validated_hooked_blocks[ $slug ] = $block;
}
return $validated_hooked_blocks;
}
/**
* Register a product details item using Block Hooks API.
*
* @param string $slug The slug of the item.
* @param array $block The block data.
* @return void
*/
private function register_hooked_block( $slug, $block ) {
add_filter(
'hooked_block_types',
function ( $hooked_block_types, $relative_position, $anchor_block_type ) use ( $slug ) {
if (
'woocommerce/accordion-group' === $anchor_block_type &&
'last_child' === $relative_position &&
! in_array( $slug, $hooked_block_types, true )
) {
$hooked_block_types[] = $slug;
}
return $hooked_block_types;
},
10,
3
);
add_filter(
"hooked_block_{$slug}",
function ( $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block ) use ( $block ) {
if (
is_null( $parsed_hooked_block ) ||
'woocommerce/accordion-group' !== $parsed_anchor_block['blockName'] ||
'last_child' !== $relative_position ||
empty( $parsed_anchor_block['attrs']['metadata']['isDescendantOfProductDetails'] )
) {
return null;
}
return $this->create_accordion_item_block( $block['title'], $block['content'] );
},
10,
4
);
}
/**
* Previously, the Product Details block was a standalone block. It doesn't
* have any inner blocks and it rendered the tabs directly like the classic
* template. When upgrading, we want the existing stores using the block to
* continue working as before, so we moved the logic the legacy render
* method here.
*
* @see https://github.com/woocommerce/woocommerce/pull/59005
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string Rendered block output.
*/
protected function render_legacy_block( $attributes, $content, $block ) {
if ( ! is_singular( 'product' ) ) {
return $content;
}
$hide_tab_title = isset( $attributes['hideTabTitle'] ) ? $attributes['hideTabTitle'] : false;
if ( $hide_tab_title ) {
add_filter( 'woocommerce_product_description_heading', '__return_empty_string' );
add_filter( 'woocommerce_product_additional_information_heading', '__return_empty_string' );
add_filter( 'woocommerce_reviews_title', '__return_empty_string' );
}
$tabs = $this->render_tabs();
if ( $hide_tab_title ) {
remove_filter( 'woocommerce_product_description_heading', '__return_empty_string' );
remove_filter( 'woocommerce_product_additional_information_heading', '__return_empty_string' );
remove_filter( 'woocommerce_reviews_title', '__return_empty_string' );
// Remove the first `h2` of every `.wc-tab`. This is required for the Reviews tabs when there are no reviews and for plugin tabs.
$tabs_html = new WP_HTML_Tag_Processor( $tabs );
while ( $tabs_html->next_tag( array( 'class_name' => 'wc-tab' ) ) ) {
if ( $tabs_html->next_tag( 'h2' ) ) {
$tabs_html->set_attribute( 'hidden', 'true' );
}
}
$tabs = $tabs_html->get_updated_html();
}
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
return sprintf(
'<div class="wp-block-woocommerce-product-details %1$s">
<div style="%2$s">
%3$s
</div>
</div>',
esc_attr( $classes_and_styles['classes'] ),
esc_attr( $classes_and_styles['styles'] ),
$tabs
);
}
/**
* Gets the tabs with their content to be rendered by the block.
*
* @return string The tabs html to be rendered by the block
*/
protected function render_tabs() {
ob_start();
rewind_posts();
while ( have_posts() ) {
the_post();
woocommerce_output_product_data_tabs();
}
$tabs = ob_get_clean();
return $tabs;
}
}