Commit be03dec74d for woocommerce

commit be03dec74d7560ace9e84ba9bff009e0cd43dd78
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Thu May 29 07:14:32 2025 -0600

    [Experimental] Blockified Add to Cart with Options block: Make 'X in cart' button text work for variable products (#57859)

    * Display an not-allowed cursor when trying to Add to Cart an invalid variation

    * Rename 'variation' to 'selectedAttributes'

    * Add changelog file

    * Simplify diff

    * Fix missing attribute to rename

    * Blockified Add to Cart with Options: make 'X in cart' button text work for variable products

    * Add changelog file

    * Fix wrong check

    * Fix missing label in simple products

    * Filter the data we pass on availableVariations

    * feat: prepare Product Button to work with variable product inside Add to Cart + Options block

    * fix: post cherry pick cleanup

    * fix: type the optional context

    * refactor: remove getContext wrap

    * fix: remove the unused state

    * refactor: dont sync state, derive it

    * [Experimental] Use regular form in Add to Cart with Options when iAPI can't be used (#57962)

    * Use regular form in Add to Cart with Options when iAPI can't be used

    * Add changelog file

    * Add missing comment

    * Make changelog a comment

    * Remove testing code

    * Code clean up

    * Rename one extra instance of is_descendent_of_add_to_cart_form to is_descendant_of_add_to_cart_form

    * chore: sync product button store with trunk

    * Update changelog files

    * Assign context when updating the attribute

    * Make variationId a derived state

    ---------

    Co-authored-by: Tung Du <dinhtungdu@gmail.com>

diff --git a/plugins/woocommerce/changelog/fix-57726-x-in-cart-variable-products b/plugins/woocommerce/changelog/fix-57726-x-in-cart-variable-products
new file mode 100644
index 0000000000..e486258951
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-57726-x-in-cart-variable-products
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Comment: Make 'X in cart' button text work for variable products in the Blockified Add to Cart with Options block and use regular form in Add to Cart with Options when iAPI can't be used (simple and grouped products)
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
index 81eae113d9..df7c96c619 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
@@ -7,7 +7,7 @@ import type { Store as WooCommerce } from '@woocommerce/stores/woocommerce/cart'
 /**
  * Internal dependencies
  */
-import type { Context as AddToCartWithOptionsContext } from '../../../../blocks/add-to-cart-with-options/frontend';
+import type { AddToCartWithOptionsStore } from '../../../../blocks/add-to-cart-with-options/frontend';

 // Stores are locked to prevent 3PD usage until the API is stable.
 const universalLock =
@@ -42,6 +42,12 @@ const { state: wooState } = store< WooCommerce >(
 	{ lock: universalLock }
 );

+const { state: addToCartWithOptionsState } = store< AddToCartWithOptionsStore >(
+	'woocommerce/add-to-cart-with-options',
+	{},
+	{ lock: universalLock }
+);
+
 const productButtonStore = {
 	state: {
 		get quantity(): number {
@@ -81,12 +87,8 @@ const productButtonStore = {
 			return state.quantity > 0;
 		},
 		get productId() {
-			const addToCartWithOptionsContext =
-				getContext< AddToCartWithOptionsContext >(
-					'woocommerce/add-to-cart-with-options'
-				);
 			return (
-				addToCartWithOptionsContext?.variationId ||
+				addToCartWithOptionsState?.variationId ||
 				getContext< Context >().productId
 			);
 		},
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
index cb99def47d..11b279c3b7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
@@ -118,17 +118,24 @@ const addToCartWithOptionsStore = store(
 	'woocommerce/add-to-cart-with-options',
 	{
 		state: {
-			get isFormValid() {
-				const { productType, availableVariations, selectedAttributes } =
-					getContext< Context >();
+			get isFormValid(): boolean {
+				const { productType } = getContext< Context >();
 				if ( productType !== 'variable' ) {
 					return true;
 				}
+				return !! addToCartWithOptionsStore.state.variationId;
+			},
+			get variationId(): number | null {
+				const context = getContext< Context >();
+				if ( ! context ) {
+					return null;
+				}
+				const { availableVariations, selectedAttributes } = context;
 				const matchedVariation = getMatchedVariation(
 					availableVariations,
 					selectedAttributes
 				);
-				return !! matchedVariation;
+				return matchedVariation?.variation_id || null;
 			},
 		},
 		actions: {
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
index f089e60a97..7a0c2578ba 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
@@ -113,6 +113,50 @@ test.describe( 'Add to Cart + Options Block', () => {
 		await expect( addToCartButton ).toHaveText( '6 in cart' );
 	} );

+	test( "'X in cart' text reflects the correct amount in variations", async ( {
+		page,
+		pageObject,
+		editor,
+	} ) => {
+		await pageObject.setFeatureFlags();
+
+		await pageObject.updateSingleProductTemplate();
+
+		await editor.saveSiteEditorEntities( {
+			isOnlyCurrentEntityDirty: true,
+		} );
+
+		await page.goto( '/hoodie' );
+
+		const logoNoOption = page.getByRole( 'radio', {
+			name: 'No',
+			exact: true,
+		} );
+		const colorBlueOption = page.getByRole( 'radio', {
+			name: 'Blue',
+			exact: true,
+		} );
+		const colorGreenOption = page.getByRole( 'radio', {
+			name: 'Green',
+			exact: true,
+		} );
+		const addToCartButton = page.getByText( 'Add to cart' ).first();
+
+		await logoNoOption.click();
+		await colorGreenOption.click();
+		await addToCartButton.click();
+
+		await expect( page.getByText( '1 in cart' ) ).toBeVisible();
+
+		await colorBlueOption.click();
+
+		await expect( page.getByText( '1 in cart' ) ).toBeHidden();
+
+		await colorGreenOption.click();
+
+		await expect( page.getByText( '1 in cart' ) ).toBeVisible();
+	} );
+
 	test( "doesn't allow selecting invalid variations in pills mode", async ( {
 		page,
 		pageObject,
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
index 716a8204ed..06aad4bb09 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
@@ -166,6 +166,14 @@ class AddToCartWithOptions extends AbstractBlock {
 			 */
 			$default_quantity = apply_filters( 'woocommerce_add_to_cart_quantity', 1, $product->get_id() );

+			wp_interactivity_state(
+				'woocommerce/add-to-cart-with-options',
+				array(
+					'isFormValid' => ! $product->is_type( 'variable' ),
+					'variationId' => null,
+				)
+			);
+
 			$context = array(
 				'productId'   => $product->get_id(),
 				'productType' => $product->get_type(),
@@ -174,23 +182,19 @@ class AddToCartWithOptions extends AbstractBlock {

 			if ( $product->is_type( 'variable' ) ) {
 				$context['selectedAttributes']  = array();
-				$context['availableVariations'] = $product->get_available_variations();
+				$available_variations           = $product->get_available_variations();
+				$available_variations_data      = array_map(
+					function ( $variation ) {
+						return array(
+							'variation_id' => $variation['variation_id'],
+							'attributes'   => $variation['attributes'],
+						);
+					},
+					$available_variations
+				);
+				$context['availableVariations'] = $available_variations_data;
 			}

-			$wrapper_attributes = get_block_wrapper_attributes(
-				array(
-					'data-wp-interactive'       => 'woocommerce/add-to-cart-with-options',
-					'data-wp-context'           => wp_json_encode(
-						$context,
-						JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
-					),
-					'data-wp-on--submit'        => 'actions.handleSubmit',
-					'data-wp-class--is-invalid' => '!state.isFormValid',
-					'class'                     => $classes,
-					'style'                     => esc_attr( $classes_and_styles['styles'] ),
-				)
-			);
-
 			$hooks_before = '';
 			$hooks_after  = '';

@@ -288,12 +292,56 @@ class AddToCartWithOptions extends AbstractBlock {
 			$template_part_blocks = do_blocks( $template_part_contents );
 			remove_filter( 'render_block_context', array( $this, 'set_is_descendant_of_add_to_cart_with_options_context' ) );

+			$wrapper_attributes = array(
+				'class'               => $classes,
+				'style'               => esc_attr( $classes_and_styles['styles'] ),
+				'data-wp-interactive' => 'woocommerce/add-to-cart-with-options',
+				'data-wp-context'     => wp_json_encode(
+					$context,
+					JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
+				),
+			);
+
+			$cart_redirect_after_add = get_option( 'woocommerce_cart_redirect_after_add' );
+			$form_attributes         = '';
+			$hidden_input            = '';
+			if ( $hooks_before || $hooks_after || 'yes' === $cart_redirect_after_add ) {
+				// If an extension is hoooking into the form or we need to redirect to the cart,
+				// we fall back to a regular HTML form.
+				$form_attributes = array(
+					'action'  => esc_url(
+						/**
+						 * Filter the add to cart form action.
+						 *
+						 * @since 10.0.0
+						 * @param string $permalink The product permalink.
+						 * @return string The add to cart form action.
+						 */
+						apply_filters( 'woocommerce_add_to_cart_form_action', $product->get_permalink() )
+					),
+					'method'  => 'post',
+					'enctype' => 'multipart/form-data',
+				);
+				if ( ProductType::SIMPLE === $product_type ) {
+					$hidden_input = '<input type="hidden" name="add-to-cart" value="' . $product->get_id() . '" />';
+				} elseif ( ProductType::GROUPED === $product_type ) {
+					$hidden_input = '<input type="hidden" name="add-to-cart" value="' . $product->get_id() . '" />';
+				}
+			} else {
+				// Otherwise, we use the Interactivity API.
+				$form_attributes = array(
+					'data-wp-on--submit'        => 'actions.handleSubmit',
+					'data-wp-class--is-invalid' => '!state.isFormValid',
+				);
+			}
+
 			$form_html = sprintf(
-				'<form %1$s>%2$s%3$s%4$s</form>',
-				$wrapper_attributes,
+				'<form %1$s>%2$s%3$s%4$s%5$s</form>',
+				get_block_wrapper_attributes( array_merge( $wrapper_attributes, $form_attributes ) ),
 				$hooks_before,
 				$template_part_blocks,
 				$hooks_after,
+				$hidden_input
 			);

 			ob_start();
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductSelectorItemCTA.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductSelectorItemCTA.php
index 396a61527b..56f9194777 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductSelectorItemCTA.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductSelectorItemCTA.php
@@ -33,7 +33,12 @@ class GroupedProductSelectorItemCTA extends AbstractBlock {
 	private function get_quantity_selector_markup( $product ) {
 		ob_start();

-		woocommerce_quantity_input( AddToCartWithOptionsUtils::get_quantity_input_args( $product ) );
+		woocommerce_quantity_input(
+			array_merge(
+				AddToCartWithOptionsUtils::get_quantity_input_args( $product ),
+				array( 'input_name' => 'quantity[' . $product->get_id() . ']' )
+			)
+		);

 		$quantity_html = ob_get_clean();

diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
index 9c59d3bd1e..a84d0d2beb 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
@@ -104,12 +104,12 @@ class ProductButton extends AbstractBlock {
 				'addToCartText' => function () {
 					$context = wp_interactivity_get_context();
 					$quantity = $context['tempQuantity'];
-					$addToCartText = $context['addToCartText'];
+					$add_to_cart_text = $context['addToCartText'];
 					return $quantity > 0 ? sprintf(
 						/* translators: %s: product number. */
 						__( '%s in cart', 'woocommerce' ),
 						$quantity
-					) : $addToCartText;
+					) : $add_to_cart_text;
 				},
 				'inTheCartText' => sprintf(
 					/* translators: %s: product number. */
@@ -124,7 +124,7 @@ class ProductButton extends AbstractBlock {
 		$cart_redirect_after_add  = get_option( 'woocommerce_cart_redirect_after_add' ) === 'yes';
 		$ajax_add_to_cart_enabled = get_option( 'woocommerce_enable_ajax_add_to_cart' ) === 'yes';
 		$is_ajax_button           = $ajax_add_to_cart_enabled && ! $cart_redirect_after_add && $product->supports( 'ajax_add_to_cart' ) && $product->is_purchasable() && $product->is_in_stock();
-		$html_element             = $is_ajax_button ? 'button' : 'a';
+		$html_element             = $is_ajax_button || ( $is_descendant_of_add_to_cart_form && 'external' !== $product->get_type() ) ? 'button' : 'a';
 		$styles_and_classes       = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes, array(), array( 'extra_classes' ) );
 		$classname                = StyleAttributesUtils::get_classes_by_attributes( $attributes, array( 'extra_classes' ) );
 		$custom_width_classes     = isset( $attributes['width'] ) ? 'has-custom-width wp-block-button__width-' . $attributes['width'] : '';
@@ -198,7 +198,7 @@ class ProductButton extends AbstractBlock {
 					array(
 						'data-product_id'  => $product->get_id(),
 						'data-product_sku' => $product->get_sku(),
-						'aria-label'       => $product->add_to_cart_description(),
+						'aria-label'       => ! $is_descendant_of_add_to_cart_form || 'simple' === $product->get_type() ? $product->add_to_cart_description() : null,
 					),
 				),
 			),
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
index f26a743967..942c132528 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
@@ -215,4 +215,39 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
 		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $simple_product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
 		$this->assertStringContainsString( 'data-block-name="woocommerce/add-to-cart-with-options-quantity-selector"', $markup, 'The Add to Cart + Options form contains a quantity selector block for products with manage stock set to true and stock quantity > 1.' );
 	}
+
+	/**
+	 * Tests that we render a regular HTML form when an extension hooks into the form or when cart redirect is enabled.
+	 *
+	 * @covers AddToCartWithOptions::render
+	 */
+	public function test_form_fallback() {
+		global $product;
+		$product = new \WC_Product_Simple();
+		$product->set_regular_price( 10 );
+		$product_id = $product->save();
+
+		update_option( 'woocommerce_cart_redirect_after_add', 'yes' );
+
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
+
+		$this->assertStringContainsString( 'action="' . $product->get_permalink() . '"', $markup, 'The form has an action that redirects to the product page when redirect after add is enabled.' );
+		$this->assertStringNotContainsString( 'data-wp-on--submit', $markup, 'The form doesn\'t have an on submit event when redirect after add is enabled.' );
+
+		update_option( 'woocommerce_cart_redirect_after_add', 'no' );
+
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
+
+		$this->assertStringNotContainsString( 'action="' . $product->get_permalink() . '"', $markup, 'The form doesn\'t have an action that redirects to the product page when redirect after add is disabled.' );
+		$this->assertStringContainsString( 'data-wp-on--submit', $markup, 'The form has an on submit event when redirect after add is disabled.' );
+
+		add_action( 'woocommerce_before_add_to_cart_button', array( $this, 'hook_into_add_to_cart_button_action' ) );
+
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
+
+		$this->assertStringContainsString( 'action="' . $product->get_permalink() . '"', $markup, 'The form has an action that redirects to the product page when an extension hooks into the form.' );
+		$this->assertStringNotContainsString( 'data-wp-on--submit', $markup, 'The form doesn\'t have an on submit event when an extension hooks into the form.' );
+
+		remove_action( 'woocommerce_before_add_to_cart_button', array( $this, 'hook_into_add_to_cart_button_action' ) );
+	}
 }