WooCommerce Code Reference

VariationSelectorAttribute.php

Source code

<?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions;

use Automattic\WooCommerce\Blocks\BlockTypes\AbstractBlock;
use Automattic\WooCommerce\Blocks\BlockTypes\EnableBlockJsonAssetsTrait;
use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermMeta;
use WP_Block;

/**
 * Block type for Variation Selector in the Add to Cart + Options block.
 */
class VariationSelectorAttribute extends AbstractBlock {

	use EnableBlockJsonAssetsTrait;

	/**
	 * Block name.
	 *
	 * @var string
	 */
	protected $block_name = 'add-to-cart-with-options-variation-selector-attribute';

	/**
	 * Extra data passed through from server to client for block.
	 *
	 * @param array $attributes  Any attributes that currently are available from the block.
	 *                           Note, this will be empty in the editor context when the block is
	 *                           not in the post content on editor load.
	 * @return void
	 */
	protected function enqueue_data( array $attributes = array() ): void {
		parent::enqueue_data( $attributes );

		if ( is_admin() ) {
			$this->asset_data_registry->add(
				'experimentalVisualAttributes',
				array_key_exists( 'wc-visual', wc_get_attribute_types() )
			);
		}
	}

	/**
	 * 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 ) {
		global $product;

		if ( ! $product instanceof \WC_Product_Variable ) {
			return '';
		}

		$content = '';

		$product_attributes            = $product->get_variation_attributes();
		$available_values_by_attribute = $this->get_available_variation_values_by_attribute_slug();

		foreach ( $product_attributes as $product_attribute_name => $product_attribute_terms ) {
			$content .= $this->render_attribute_row( $product_attribute_name, $product_attribute_terms, $block, $attributes, $available_values_by_attribute );
		}

		return $content;
	}

	/**
	 * Get attribute row HTML.
	 *
	 * @param string   $attribute_name Product Attribute Name.
	 * @param array    $product_attribute_terms Product Attribute Terms.
	 * @param WP_Block $block The Block.
	 * @param array    $attributes Template block attributes (displayStyle, autoselect, etc.).
	 * @param array    $available_values_by_attribute Variation values keyed by attribute slug.
	 * @return string Row HTML
	 */
	private function render_attribute_row( string $attribute_name, array $product_attribute_terms, WP_Block $block, array $attributes, array $available_values_by_attribute ): string {
		$inner_blocks = $block->parsed_block['innerBlocks'] ?? array();

		if ( empty( $inner_blocks ) ) {
			return '';
		}

		$attribute_slug  = wc_variation_attribute_name( $attribute_name );
		$attribute_terms = $this->get_filtered_attribute_terms(
			$attribute_name,
			$product_attribute_terms,
			$available_values_by_attribute[ $attribute_slug ] ?? array()
		);

		if ( empty( $attribute_terms ) ) {
			return '';
		}

		$default_selected = $this->get_default_selected_attribute( $attribute_slug, $attribute_terms );
		$variation_items  = $this->build_variation_selectable_items( $attribute_name, $attribute_slug, $attribute_terms, $default_selected );
		$attribute_label  = wc_attribute_label( $attribute_name );
		$attribute_id     = 'wc_product_attribute_' . uniqid();
		$context          = array(
			'woocommerce/attributeId'     => $attribute_id,
			'woocommerce/attributeName'   => $attribute_name,
			'woocommerce/attributeTerms'  => $attribute_terms,
			'woocommerce/selectableItems' => array(
				'items'          => $variation_items,
				'selectionMode'  => 'single',
				'storeNamespace' => 'woocommerce/add-to-cart-with-options',
				'groupLabel'     => $attribute_label,
			),
		);

		$inner_html = '';

		foreach ( $inner_blocks as $inner_block ) {
			$inner_block = $this->replace_legacy_attribute_options_block( $inner_block, $attributes );
			$inner_html .= ( new WP_Block( $inner_block, $context ) )->render();
		}

		$interactive_context = array(
			'name'                      => $attribute_label,
			'variationAttributeOptions' => $variation_items,
			'selectedValue'             => $default_selected,
			'autoselect'                => $attributes['autoselect'] ?? false,
			'disabledAttributesAction'  => $attributes['disabledAttributesAction'] ?? 'disable',
		);

		$interactive_attributes = array(
			'data-wp-interactive' => 'woocommerce/add-to-cart-with-options',
			'data-wp-init'        => 'callbacks.setDefaultSelectedAttribute',
		);

		// Hidden input for legacy form POST submissions (page refresh). Chips and
		// dropdown UI elements do not include name="attribute_*" fields.
		$hidden_attribute_input = sprintf(
			'<input type="hidden" name="%1$s" value="%2$s" data-wp-bind--value="context.selectedValue" />',
			esc_attr( $attribute_slug ),
			esc_attr( $default_selected ?? '' )
		);

		return sprintf(
			'<div %s %s>%s%s</div>',
			get_block_wrapper_attributes( $interactive_attributes ),
			wp_interactivity_data_wp_context( $interactive_context ),
			$inner_html,
			$hidden_attribute_input
		);
	}

	/**
	 * Replace legacy Attribute Options block and apply its settings to the parent attributes.
	 *
	 * @param array $inner_block  The inner block to replace.
	 * @param array $attributes   Parent block attributes, updated when attributes in the legacy Attribute Options block are found.
	 * @return array The replaced inner block.
	 */
	private function replace_legacy_attribute_options_block( array $inner_block, array &$attributes ): array {
		if ( 'woocommerce/add-to-cart-with-options-variation-selector-attribute-options' === $inner_block['blockName'] ) {
			$legacy_attrs = $inner_block['attrs'] ?? array();

			if ( array_key_exists( 'autoselect', $legacy_attrs ) && true === $legacy_attrs['autoselect'] ) {
				$attributes['autoselect'] = true;
			}

			if ( array_key_exists( 'disabledAttributesAction', $legacy_attrs ) && 'hide' === $legacy_attrs['disabledAttributesAction'] ) {
				$attributes['disabledAttributesAction'] = 'hide';
			}

			if ( array_key_exists( 'optionStyle', $legacy_attrs ) && 'dropdown' === $legacy_attrs['optionStyle'] ) {
				$attributes['displayStyle'] = 'woocommerce/dropdown';
			}

			return array(
				'blockName'    => 'woocommerce/dropdown' === $attributes['displayStyle'] ? 'woocommerce/dropdown' : 'woocommerce/product-filter-chips',
				'attrs'        => array(),
				'innerBlocks'  => array(),
				'innerHTML'    => '',
				'innerContent' => array(),
			);
		} elseif ( isset( $inner_block['innerBlocks'] ) && is_array( $inner_block['innerBlocks'] ) && ! empty( $inner_block['innerBlocks'] ) ) {
			foreach ( $inner_block['innerBlocks'] as $key => $child_inner_block ) {
				$inner_block['innerBlocks'][ $key ] = $this->replace_legacy_attribute_options_block( $child_inner_block, $attributes );
			}
		}

		return $inner_block;
	}

	/**
	 * Build filtered attribute term options for the variation selector.
	 *
	 * @param string $attribute_name Product attribute name.
	 * @param array  $product_attribute_terms Custom attribute terms when not a taxonomy.
	 * @param array  $available_values Available variation values for this attribute (value => true).
	 * @return array Attribute terms, or empty string when none match.
	 */
	private function get_filtered_attribute_terms( string $attribute_name, array $product_attribute_terms, array $available_values ) {
		global $product;

		$selected_attribute = $product->get_variation_default_attribute( $attribute_name );
		$terms              = taxonomy_exists( $attribute_name )
			? wc_get_product_terms( $product->get_id(), $attribute_name, array( 'fields' => 'all' ) )
			: $product_attribute_terms;

		$attribute_terms = array();
		if ( ! empty( $available_values ) ) {
			$allows_any_value = isset( $available_values[''] );

			foreach ( $terms as $term ) {
				$option = $this->map_term_to_option( $term, $attribute_name, $product, $selected_attribute );

				if ( ! isset( $option['value'] ) ) {
					continue;
				}

				if ( $allows_any_value || isset( $available_values[ $option['value'] ] ) ) {
					$attribute_terms[] = $option;
				}
			}
		}

		return $attribute_terms;
	}

	/**
	 * Build a lookup of attribute values used by available variations.
	 *
	 * @return array Map of attribute slug to set of values (keys are values).
	 */
	private function get_available_variation_values_by_attribute_slug(): array {
		global $product;

		$product_variations = $product->get_available_variations( 'objects' );
		$available_by_slug  = array();

		foreach ( $product_variations as $variation ) {
			foreach ( $variation->get_variation_attributes() as $attribute_slug => $value ) {
				$available_by_slug[ $attribute_slug ][ $value ] = true;
			}
		}

		return $available_by_slug;
	}

	/**
	 * Get the default selected attribute.
	 *
	 * @param string $attribute_slug The attribute's slug.
	 * @param array  $attribute_terms The attribute's terms.
	 * @return string|null The default selected attribute.
	 */
	private function get_default_selected_attribute( string $attribute_slug, array $attribute_terms ): ?string {
		if ( isset( $_GET[ $attribute_slug ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$raw = wp_unslash( $_GET[ $attribute_slug ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			if ( is_string( $raw ) ) {
				$attribute_slug_from_request = sanitize_title( $raw );
				foreach ( $attribute_terms as $attribute_term ) {
					if ( sanitize_title( $attribute_term['value'] ) === $attribute_slug_from_request ) {
						return $attribute_term['value'];
					}
				}
			}
		} else {
			foreach ( $attribute_terms as $attribute_term ) {
				if ( $attribute_term['isSelected'] ) {
					return $attribute_term['value'];
				}
			}
		}

		return null;
	}

	/**
	 * Build selectable items for the inner block protocol and client context.
	 *
	 * @param string      $attribute_name Product attribute name.
	 * @param string      $attribute_slug Attribute slug.
	 * @param array       $attribute_terms Terms from context.
	 * @param string|null $default_selected Default selected attribute value.
	 * @return array
	 */
	private function build_variation_selectable_items( string $attribute_name, string $attribute_slug, array $attribute_terms, ?string $default_selected ): array {
		$id_prefix    = sanitize_title( $attribute_slug );
		$items        = array();
		$term_visuals = VisualAttributeTermMeta::is_visual_attribute_taxonomy( $attribute_name )
			? VisualAttributeTermMeta::get_term_visuals( wp_list_pluck( $attribute_terms, 'term_id' ) )
			: array();

		foreach ( $attribute_terms as $attribute_term ) {
			if ( ! is_array( $attribute_term ) || ! isset( $attribute_term['value'], $attribute_term['label'] ) ) {
				continue;
			}
			$value = (string) $attribute_term['value'];
			$slug  = sanitize_title( $value );
			$item  = array(
				'id'        => $id_prefix . '-' . $slug,
				'label'     => (string) $attribute_term['label'],
				'value'     => $value,
				'ariaLabel' => (string) $attribute_term['label'],
				'selected'  => $default_selected === $value,
			);

			if ( isset( $attribute_term['term_id'], $term_visuals[ $attribute_term['term_id'] ] ) ) {
				$item['visual'] = $term_visuals[ $attribute_term['term_id'] ];
			}

			$items[] = $item;
		}

		return $items;
	}

	/**
	 * Map a taxonomy term or custom attribute option to the variation row option shape.
	 *
	 * @param \WP_Term|string $term Term object for taxonomies, option string for custom attributes.
	 * @param string          $attribute_name Name of the attribute.
	 * @param \WC_Product     $product Product object.
	 * @param string          $selected_attribute Default selected attribute value.
	 * @return array
	 */
	private function map_term_to_option( $term, string $attribute_name, \WC_Product $product, string $selected_attribute ): array {
		if ( $term instanceof \WP_Term ) {
			$value       = $term->slug;
			$label       = $term->name;
			$filter_item = $term;
		} elseif ( is_string( $term ) ) {
			$value       = $term;
			$label       = $term;
			$filter_item = null;
		} else {
			return array();
		}

		$option = array(
			'value'      => $value,
			/**
			 * Filter the variation option name.
			 *
			 * @since 9.7.0
			 *
			 * @param string                $option_label   The option label.
			 * @param \WP_Term|string|null $item            Term object for taxonomies, option string for custom attributes.
			 * @param string                $attribute_name Name of the attribute.
			 * @param \WC_Product           $product        Product object.
			 */
			'label'      => apply_filters(
				'woocommerce_variation_option_name',
				$label,
				$filter_item,
				$attribute_name,
				$product
			),
			'isSelected' => $selected_attribute === $value,
		);

		if ( $term instanceof \WP_Term ) {
			$option['term_id'] = $term->term_id;
		}

		return $option;
	}
}