Commit d8d0a13f3dd for woocommerce
commit d8d0a13f3ddd4dd61737657c840a374c70adbd9b
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date: Sun Nov 15 12:35:03 2026 +0000
Fix catalog add-to-cart minimum quantities (#70915)
* Fix catalog add-to-cart minimum quantities
* Add changelog entry for catalog minimum quantities
* Fix catalog quantity consistency across buttons and links
* Refactor catalog button data into ProductUtil
* Preserve filtered add-to-cart URL quantities
* Consolidate catalog quantity override tests
* Respect sold-individually limits before URL quantities
* Update plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* Resolve quantities when rendering loop buttons directly
* Refactor loop button preparation outside the template
* Simplify loop button template arguments
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-46326-loop-minimum-quantity b/plugins/woocommerce/changelog/fix-46326-loop-minimum-quantity
new file mode 100644
index 00000000000..757b91486cc
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-46326-loop-minimum-quantity
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Make catalog add-to-cart buttons add the minimum quantity and show how many items will be added.
diff --git a/plugins/woocommerce/includes/class-wc-product-simple.php b/plugins/woocommerce/includes/class-wc-product-simple.php
index fb858443e71..26601a4d490 100644
--- a/plugins/woocommerce/includes/class-wc-product-simple.php
+++ b/plugins/woocommerce/includes/class-wc-product-simple.php
@@ -41,12 +41,16 @@ class WC_Product_Simple extends WC_Product {
* @return string
*/
public function add_to_cart_url() {
+ $quantity = $this->is_sold_individually() ? 1 : $this->get_min_purchase_quantity();
+ $query_args = array(
+ 'add-to-cart' => $this->get_id(),
+ 'quantity' => $quantity > 1 ? $quantity : false,
+ );
+
$url = $this->is_purchasable() && $this->is_in_stock() ? remove_query_arg(
'added-to-cart',
add_query_arg(
- array(
- 'add-to-cart' => $this->get_id(),
- ),
+ $query_args,
( function_exists( 'is_feed' ) && is_feed() ) || ( function_exists( 'is_404' ) && is_404() ) ? $this->get_permalink() : false
)
) : $this->get_permalink();
@@ -73,8 +77,16 @@ class WC_Product_Simple extends WC_Product {
public function add_to_cart_description() {
/* translators: %s: Product title */
$text = $this->is_purchasable() && $this->is_in_stock() ? __( 'Add to cart: “%s”', 'woocommerce' ) : __( 'Read more about “%s”', 'woocommerce' );
+ $text = sprintf( $text, $this->get_name() );
- return apply_filters( 'woocommerce_product_add_to_cart_description', sprintf( $text, $this->get_name() ), $this );
+ /**
+ * Filters the add to cart button description used in accessible labels.
+ *
+ * @param string $text The button description.
+ * @param WC_Product_Simple $product The product object.
+ * @since 3.3.0
+ */
+ return apply_filters( 'woocommerce_product_add_to_cart_description', $text, $this );
}
/**
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index a4bd5bcc307..37dd2c98868 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -1470,7 +1470,7 @@ if ( ! function_exists( 'woocommerce_template_loop_add_to_cart' ) ) {
}
$defaults = array(
- 'quantity' => 1,
+ 'quantity' => wc_get_container()->get( \Automattic\WooCommerce\Internal\Utilities\ProductUtil::class )->get_catalog_add_to_cart_quantity( $product ),
'class' => implode(
' ',
array_filter(
@@ -1508,6 +1508,11 @@ if ( ! function_exists( 'woocommerce_template_loop_add_to_cart' ) ) {
$args['attributes']['aria-label'] = wp_strip_all_tags( $args['attributes']['aria-label'] );
}
+ $button_data = wc_get_container()->get( \Automattic\WooCommerce\Internal\Utilities\ProductUtil::class )->get_catalog_add_to_cart_button_data( $product, $args['quantity'] ?? null );
+ if ( ( $args['attributes']['aria-label'] ?? null ) === wp_strip_all_tags( $button_data['original_description'] ) ) {
+ $args['attributes']['aria-label'] = wp_strip_all_tags( $button_data['description'] );
+ }
+
$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';
@@ -1524,6 +1529,7 @@ if ( ! function_exists( 'woocommerce_template_loop_add_to_cart' ) ) {
$args['attributes']['role'] = 'button';
}
+ $args['button_data'] = $button_data;
wc_get_template( 'loop/add-to-cart.php', $args );
}
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php b/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php
index 80ef051ba68..09f6a87fec1 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php
@@ -5,6 +5,7 @@ use Automattic\WooCommerce\Blocks\Utils\BlocksWpQuery;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
/**
* AbstractProductGrid class.
@@ -654,7 +655,7 @@ abstract class AbstractProductGrid extends AbstractDynamicBlock {
protected function get_add_to_cart( $product ) {
$attributes = array(
'aria-label' => $product->add_to_cart_description(),
- 'data-quantity' => '1',
+ 'data-quantity' => wc_get_container()->get( ProductUtil::class )->get_catalog_add_to_cart_quantity( $product ),
'data-product_id' => $product->get_id(),
'data-product_sku' => $product->get_sku(),
'data-price' => wc_get_price_to_display( $product ),
@@ -680,13 +681,17 @@ abstract class AbstractProductGrid extends AbstractDynamicBlock {
*
* @return array Returns an associative array derived from the default array passed as an argument and added the extra HTML attributes.
*/
- $attributes = apply_filters( 'woocommerce_blocks_product_grid_add_to_cart_attributes', $attributes, $product );
+ $attributes = apply_filters( 'woocommerce_blocks_product_grid_add_to_cart_attributes', $attributes, $product );
+ $button_data = wc_get_container()->get( ProductUtil::class )->get_catalog_add_to_cart_button_data( $product, $attributes['data-quantity'] ?? 1 );
+ if ( ( $attributes['aria-label'] ?? null ) === $button_data['original_description'] ) {
+ $attributes['aria-label'] = $button_data['description'];
+ }
return sprintf(
'<a href="%s" %s>%s</a>',
- esc_url( $product->add_to_cart_url() ),
+ esc_url( $button_data['url'] ),
wc_implode_html_attributes( $attributes ),
- esc_html( $product->add_to_cart_text() )
+ esc_html( $button_data['text'] )
);
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
index 580f70a04b0..de4edc6ee77 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\Utils;
use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\Utilities\InCartQuantity;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
/**
* ProductButton class.
@@ -145,6 +146,8 @@ class ProductButton extends AbstractBlock {
}
}
+ $initial_quantity = $is_descendant_of_add_to_cart_form ? 1 : wc_get_container()->get( ProductUtil::class )->get_catalog_add_to_cart_quantity( $product );
+
/**
* This filter is documented in includes/wc-template-functions.php.
*
@@ -153,7 +156,7 @@ class ProductButton extends AbstractBlock {
$args = apply_filters(
'woocommerce_loop_add_to_cart_args',
array(
- 'quantity' => 1,
+ 'quantity' => $initial_quantity,
'class' => $html_classes,
'attributes' => array_merge(
$attributes,
@@ -173,6 +176,17 @@ class ProductButton extends AbstractBlock {
$default_quantity = wc_stock_amount( (float) $args['quantity'] );
}
+ if ( ! $is_descendant_of_add_to_cart_form ) {
+ $button_data = wc_get_container()->get( ProductUtil::class )->get_catalog_add_to_cart_button_data( $product, $default_quantity );
+ $add_to_cart_text = $button_data['text'] ?? $add_to_cart_text;
+ if ( isset( $add_to_cart_url, $args['attributes']['href'] ) && esc_url( $add_to_cart_url ) === $args['attributes']['href'] ) {
+ $args['attributes']['href'] = esc_url( $button_data['url'] );
+ }
+ if ( ( $args['attributes']['aria-label'] ?? null ) === $button_data['original_description'] ) {
+ $args['attributes']['aria-label'] = $button_data['description'];
+ }
+ }
+
$context = array(
'quantityToAdd' => $default_quantity,
'addToCartText' => $add_to_cart_text,
diff --git a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
index 365890855d9..6673e0cef69 100644
--- a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
@@ -10,6 +10,80 @@ use Automattic\WooCommerce\Caches\ProductCountCache;
* Class with general utility methods related to products.
*/
class ProductUtil {
+ /**
+ * Get the default catalog quantity without changing a filtered add-to-cart URL.
+ *
+ * @since 11.4.0
+ * @param \WC_Product $product Product to render.
+ * @param string|null $url Filtered URL, if already available.
+ * @return int|float
+ */
+ public function get_catalog_add_to_cart_quantity( \WC_Product $product, ?string $url = null ) {
+ if ( $product->is_sold_individually() ) {
+ return 1;
+ }
+
+ $url = $url ?? $product->add_to_cart_url();
+ parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query );
+ if ( ( $query['add-to-cart'] ?? null ) === (string) $product->get_id() && is_numeric( $query['quantity'] ?? null ) ) {
+ $quantity = wc_stock_amount( (float) $query['quantity'] );
+ if ( $quantity > 0 ) {
+ return $quantity;
+ }
+ }
+
+ return max( 1, $product->get_min_purchase_quantity() );
+ }
+
+ /**
+ * Get catalog button data, using the filtered URL quantity unless explicitly overridden.
+ *
+ * @since 11.4.0
+ * @param \WC_Product $product Product to render.
+ * @param mixed $quantity Explicit button quantity, or null to resolve the default from the URL and product minimum.
+ * @return array{url: string, text: string, description: string, original_description: string, quantity: mixed}
+ */
+ public function get_catalog_add_to_cart_button_data( \WC_Product $product, $quantity = null ): array {
+ $description = $product->add_to_cart_description();
+ $data = array(
+ 'url' => $product->add_to_cart_url(),
+ 'text' => $product->add_to_cart_text(),
+ 'description' => $description,
+ 'original_description' => $description,
+ );
+
+ $quantity = $quantity ?? $this->get_catalog_add_to_cart_quantity( $product, $data['url'] );
+ $data['quantity'] = $quantity;
+
+ if ( ! $product instanceof \WC_Product_Simple || ! $product->is_purchasable() || ! $product->is_in_stock() || ! is_numeric( $quantity ) || $quantity <= 0 ) {
+ return $data;
+ }
+
+ $quantity = wc_stock_amount( (float) $quantity );
+ parse_str( (string) wp_parse_url( $data['url'], PHP_URL_QUERY ), $query );
+ if ( ( $query['add-to-cart'] ?? null ) === (string) $product->get_id() ) {
+ $url_quantity = $query['quantity'] ?? null;
+ if ( ! is_numeric( $url_quantity ) || wc_stock_amount( (float) $url_quantity ) !== $quantity ) {
+ $data['url'] = add_query_arg( 'quantity', $quantity, $data['url'] );
+ }
+ }
+ $data['quantity'] = $quantity;
+
+ if ( $quantity > 1 ) {
+ if ( __( 'Add to cart', 'woocommerce' ) === $data['text'] ) {
+ /* translators: %s: Quantity of the product to add to the cart. */
+ $data['text'] = sprintf( __( 'Add %s to cart', 'woocommerce' ), $quantity );
+ }
+ /* translators: %s: Product title. */
+ if ( sprintf( __( 'Add to cart: “%s”', 'woocommerce' ), $product->get_name() ) === $description ) {
+ /* translators: 1: Quantity of the product to add to the cart, 2: Product title. */
+ $data['description'] = sprintf( __( 'Add %1$s to cart: “%2$s”', 'woocommerce' ), $quantity, $product->get_name() );
+ }
+ }
+
+ return $data;
+ }
+
/**
* Transient name for the cached dashboard "out of stock" product count.
*
diff --git a/plugins/woocommerce/templates/loop/add-to-cart.php b/plugins/woocommerce/templates/loop/add-to-cart.php
index b06497c6fbb..97a535c5cf0 100644
--- a/plugins/woocommerce/templates/loop/add-to-cart.php
+++ b/plugins/woocommerce/templates/loop/add-to-cart.php
@@ -12,7 +12,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 9.2.0
+ * @version 11.4.0
*/
if ( ! defined( 'ABSPATH' ) ) {
@@ -21,6 +21,16 @@ if ( ! defined( 'ABSPATH' ) ) {
global $product;
+$args = $args ?? array();
+
+// Direct template callers may not supply the data prepared by woocommerce_template_loop_add_to_cart().
+$button_data = $button_data ?? array(
+ 'url' => $product->add_to_cart_url(),
+ 'text' => $product->add_to_cart_text(),
+ 'quantity' => $args['quantity'] ?? 1,
+);
+unset( $args['button_data'] );
+
$aria_describedby = isset( $args['aria-describedby_text'] ) ? sprintf( 'aria-describedby="woocommerce_loop_add_to_cart_link_describedby_%s"', esc_attr( $product->get_id() ) ) : '';
/**
@@ -36,12 +46,12 @@ echo apply_filters( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEsc
'woocommerce_loop_add_to_cart_link',
sprintf(
'<a href="%s" %s data-quantity="%s" class="%s" %s>%s</a>',
- esc_url( $product->add_to_cart_url() ),
+ esc_url( $button_data['url'] ),
$aria_describedby,
- esc_attr( isset( $args['quantity'] ) ? $args['quantity'] : 1 ),
+ esc_attr( $button_data['quantity'] ),
esc_attr( isset( $args['class'] ) ? $args['class'] : 'button' ),
isset( $args['attributes'] ) ? wc_implode_html_attributes( $args['attributes'] ) : '',
- esc_html( $product->add_to_cart_text() )
+ esc_html( $button_data['text'] )
),
$product,
$args
diff --git a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
index bf17e936906..bb1d069c4ca 100644
--- a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
@@ -52,9 +52,10 @@ class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {
* Render the loop add-to-cart template for a product.
*
* @param WC_Product $test_product Product to render.
+ * @param array $args Arguments for the loop button.
* @return string Rendered template markup.
*/
- private function render_loop_add_to_cart( WC_Product $test_product ): string {
+ private function render_loop_add_to_cart( WC_Product $test_product, array $args = array() ): string {
global $product;
$previous_product = $product;
@@ -63,7 +64,7 @@ class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {
ob_start();
try {
- woocommerce_template_loop_add_to_cart();
+ woocommerce_template_loop_add_to_cart( $args );
return (string) ob_get_clean();
} finally {
@@ -293,6 +294,205 @@ class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {
$this->assertStringContainsString( 'rel="nofollow"', $markup );
}
+ /**
+ * @testdox Loop buttons use and announce the minimum quantity without changing sold-individually products.
+ * @testWith [1, false, 1]
+ * [10, false, 10]
+ * [0, false, 1]
+ * [10, true, 1]
+ *
+ * @param int $minimum Minimum purchase quantity.
+ * @param bool $sold_individually Whether the product is sold individually.
+ * @param int $expected_quantity Expected quantity added by the button.
+ */
+ public function test_loop_button_uses_minimum_purchase_quantity( int $minimum, bool $sold_individually, int $expected_quantity ): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_sold_individually( $sold_individually );
+ $filter = static function () use ( $minimum ) {
+ return $minimum;
+ };
+
+ add_filter( 'woocommerce_quantity_input_min', $filter );
+ try {
+ $markup = $this->render_loop_add_to_cart( $product );
+ } finally {
+ remove_filter( 'woocommerce_quantity_input_min', $filter );
+ }
+
+ $processor = new WP_HTML_Tag_Processor( $markup );
+ $this->assertTrue( $processor->next_tag( 'a' ), 'The loop button should be rendered.' );
+ $this->assertSame( (string) $expected_quantity, $processor->get_attribute( 'data-quantity' ), 'AJAX should add the minimum quantity.' );
+ parse_str( wp_parse_url( $processor->get_attribute( 'href' ), PHP_URL_QUERY ), $query );
+ $this->assertSame( (string) $product->get_id(), $query['add-to-cart'], 'The link should add the correct product.' );
+ $this->assertSame( (string) $expected_quantity, $query['quantity'] ?? '1', 'Non-AJAX requests should add the same quantity as AJAX.' );
+
+ $expected_text = $expected_quantity > 1 ? 'Add ' . $expected_quantity . ' to cart' : 'Add to cart';
+ $this->assertStringContainsString( '>' . $expected_text . '</a>', $markup, 'The button should disclose how many items will be added.' );
+ $this->assertStringContainsString( $expected_text, $processor->get_attribute( 'aria-label' ), 'The accessible name should include the visible button text.' );
+ }
+
+ /**
+ * @testdox Classic buttons preserve URL-filter quantities unless arguments or argument filters explicitly override them.
+ */
+ public function test_loop_button_retains_filtered_url_quantity(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $minimum = static fn() => 10;
+ $url_filter = static fn( $url ) => add_query_arg( 'quantity', 30, $url );
+ $args_filter = static function ( $args ) {
+ $args['quantity'] = 10;
+ return $args;
+ };
+ add_filter( 'woocommerce_quantity_input_min', $minimum );
+ add_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+ try {
+ $markup = $this->render_loop_add_to_cart( $product );
+ $this->assertStringContainsString( 'data-quantity="30"', $markup );
+ $this->assertStringContainsString( '>Add 30 to cart</a>', $markup );
+ $this->assertStringContainsString( 'aria-label="Add 30 to cart:', $markup );
+ $this->assertStringContainsString( 'quantity=30', $markup );
+
+ $markup = $this->render_loop_add_to_cart( $product, array( 'quantity' => 10 ) );
+ $this->assertStringContainsString( 'data-quantity="10"', $markup );
+ $this->assertStringContainsString( '>Add 10 to cart</a>', $markup );
+ $this->assertStringContainsString( 'aria-label="Add 10 to cart:', $markup );
+ $this->assertStringContainsString( 'quantity=10', $markup );
+
+ add_filter( 'woocommerce_loop_add_to_cart_args', $args_filter );
+ $markup = $this->render_loop_add_to_cart( $product );
+ $this->assertStringContainsString( 'data-quantity="10"', $markup );
+ $this->assertStringContainsString( '>Add 10 to cart</a>', $markup );
+ $this->assertStringContainsString( 'aria-label="Add 10 to cart:', $markup );
+ $this->assertStringContainsString( 'quantity=10', $markup );
+ } finally {
+ remove_filter( 'woocommerce_quantity_input_min', $minimum );
+ remove_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+ remove_filter( 'woocommerce_loop_add_to_cart_args', $args_filter );
+ }
+ }
+
+ /**
+ * @testdox Direct template rendering retains legacy defaults without requiring prepared button data.
+ */
+ public function test_loop_template_resolves_quantity_when_rendered_directly(): void {
+ global $product;
+
+ $previous_product = $product;
+ $product = WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+ $url_filter = static fn( $url ) => add_query_arg( 'quantity', 12, $url );
+ add_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+ try {
+ $markup = wc_get_template_html( 'loop/add-to-cart.php' );
+ $explicit_markup = wc_get_template_html( 'loop/add-to-cart.php', array( 'quantity' => 1 ) );
+ } finally {
+ remove_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+ $product = $previous_product;
+ }
+
+ $processor = new WP_HTML_Tag_Processor( $markup );
+ $this->assertTrue( $processor->next_tag( 'a' ), 'The template should render a link without quantity arguments.' );
+ $this->assertSame( '1', $processor->get_attribute( 'data-quantity' ), 'Direct callers should retain the legacy default quantity.' );
+ parse_str( wp_parse_url( $processor->get_attribute( 'href' ), PHP_URL_QUERY ), $query );
+ $this->assertSame( (string) $product_id, $query['add-to-cart'] );
+ $this->assertSame( '12', $query['quantity'], 'The filtered URL quantity should be retained.' );
+ $this->assertStringContainsString( '>Add to cart</a>', $markup );
+ $processor = new WP_HTML_Tag_Processor( $explicit_markup );
+ $this->assertTrue( $processor->next_tag( 'a' ) );
+ $this->assertSame( '1', $processor->get_attribute( 'data-quantity' ), 'Direct callers should retain their explicit quantity.' );
+ parse_str( wp_parse_url( $processor->get_attribute( 'href' ), PHP_URL_QUERY ), $query );
+ $this->assertSame( '12', $query['quantity'], 'Direct callers should retain the filtered product URL.' );
+ $this->assertStringContainsString( '>Add to cart</a>', $explicit_markup );
+ }
+
+ /**
+ * @testdox Loop rendering passes prepared button data alongside the existing template arguments.
+ */
+ public function test_loop_button_passes_prepared_data_without_replacing_template_args(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $template_args = null;
+ $link_args = null;
+ $template_filter = static function ( $template, $template_name, $args ) use ( &$template_args ) {
+ if ( 'loop/add-to-cart.php' === $template_name ) {
+ $template_args = $args;
+ }
+ return $template;
+ };
+ $link_filter = static function ( $html, $product, $args ) use ( &$link_args ) {
+ $link_args = $args;
+ return $html;
+ };
+
+ add_filter( 'wc_get_template', $template_filter, 10, 3 );
+ add_filter( 'woocommerce_loop_add_to_cart_link', $link_filter, 10, 3 );
+ try {
+ $this->render_loop_add_to_cart(
+ $product,
+ array(
+ 'quantity' => '3',
+ 'class' => 'custom-button',
+ 'custom_arg' => 'extension-value',
+ 'attributes' => array( 'aria-label' => 'Custom accessible label' ),
+ )
+ );
+ } finally {
+ remove_filter( 'wc_get_template', $template_filter );
+ remove_filter( 'woocommerce_loop_add_to_cart_link', $link_filter );
+ }
+
+ $this->assertSame( '3', $template_args['quantity'], 'Existing overrides must retain the original quantity value and type.' );
+ $this->assertSame( 'custom-button', $template_args['class'] );
+ $this->assertSame( 'extension-value', $template_args['custom_arg'] );
+ $this->assertSame( 'Custom accessible label', $template_args['attributes']['aria-label'] );
+ $this->assertSame( 3, $template_args['button_data']['quantity'] );
+ $this->assertSame( 'Add 3 to cart', $template_args['button_data']['text'] );
+ $this->assertArrayNotHasKey( 'button_data', $link_args, 'Prepared template data must not change the link filter arguments.' );
+ unset( $template_args['button_data'] );
+ $this->assertSame( $template_args, $link_args, 'The template must retain all existing loop arguments.' );
+ }
+
+ /**
+ * @testdox Add-to-cart URLs do not inherit another product's minimum quantity from the current request.
+ */
+ public function test_loop_button_does_not_inherit_request_quantity(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $previous_uri = $_SERVER['REQUEST_URI'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Preserve the original request URI for restoration.
+
+ $_SERVER['REQUEST_URI'] = '/shop/?quantity=10';
+ try {
+ $url = $product->add_to_cart_url();
+ } finally {
+ if ( null === $previous_uri ) {
+ unset( $_SERVER['REQUEST_URI'] );
+ } else {
+ $_SERVER['REQUEST_URI'] = $previous_uri;
+ }
+ }
+
+ parse_str( wp_parse_url( $url, PHP_URL_QUERY ), $query );
+ $this->assertArrayNotHasKey( 'quantity', $query, 'An ordinary product should not inherit a previous bulk quantity.' );
+ }
+
+ /**
+ * @testdox Minimum quantities do not change links or labels for unavailable products.
+ */
+ public function test_loop_button_minimum_does_not_change_unavailable_products(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( '' );
+ $filter = static function () {
+ return 10;
+ };
+
+ add_filter( 'woocommerce_quantity_input_min', $filter );
+ try {
+ $markup = $this->render_loop_add_to_cart( $product );
+ } finally {
+ remove_filter( 'woocommerce_quantity_input_min', $filter );
+ }
+
+ $this->assertStringContainsString( 'href="' . esc_url( $product->get_permalink() ) . '"', $markup, 'Unavailable products should link to their product page.' );
+ $this->assertStringContainsString( '>Read more</a>', $markup, 'Unavailable products should retain their label.' );
+ }
+
/**
* @testdox Loop buttons retain nofollow on external product links.
*/
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
index 9b12dab0a9a..37f75c65a1d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
@@ -221,23 +221,89 @@ class ProductButton extends \WP_UnitTestCase {
}
/**
- * @testdox woocommerce_loop_add_to_cart_args sets the Product Button quantity.
+ * @testdox Product Button adds and announces the minimum quantity with or without AJAX.
+ * @testWith [true]
+ * [false]
+ *
+ * @param bool $ajax_enabled Whether AJAX add to cart is enabled.
*/
- public function test_loop_add_to_cart_args_sets_initial_quantity(): void {
+ public function test_product_button_uses_minimum_purchase_quantity( bool $ajax_enabled ): void {
+ update_option( 'woocommerce_enable_ajax_add_to_cart', $ajax_enabled ? 'yes' : 'no' );
$product = WC_Helper_Product::create_simple_product();
- $filter = static function ( array $args ): array {
- $args['quantity'] = 4;
+ $filter = static function () {
+ return 10;
+ };
+
+ add_filter( 'woocommerce_quantity_input_min', $filter );
+ try {
+ $markup = $this->render_product_button( $product );
+ } finally {
+ remove_filter( 'woocommerce_quantity_input_min', $filter );
+ }
+
+ $this->assertSame( 10, $this->get_quantity_to_add( $markup ), 'The button context should use the minimum purchase quantity.' );
+ $this->assertStringContainsString( '>Add 10 to cart</span>', $markup, 'The button should disclose the quantity being added.' );
+ $processor = new \WP_HTML_Tag_Processor( $markup );
+ $this->assertTrue( $processor->next_tag( $ajax_enabled ? 'button' : 'a' ), 'The product button should be rendered.' );
+ $this->assertStringContainsString( 'Add 10 to cart', $processor->get_attribute( 'aria-label' ), 'The accessible name should include the visible button text.' );
+ if ( ! $ajax_enabled ) {
+ parse_str( wp_parse_url( $processor->get_attribute( 'href' ), PHP_URL_QUERY ), $query );
+ $this->assertSame( '10', $query['quantity'] ?? null, 'The non-AJAX link should add the minimum quantity.' );
+ }
+ }
+
+ /**
+ * @testdox Product Button still adds one item for sold-individually products when a global minimum is filtered.
+ */
+ public function test_product_button_minimum_respects_sold_individually(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_sold_individually( true );
+ $product->save();
+ $filter = static function () {
+ return 10;
+ };
+
+ add_filter( 'woocommerce_quantity_input_min', $filter );
+ try {
+ $markup = $this->render_product_button( $product );
+ } finally {
+ remove_filter( 'woocommerce_quantity_input_min', $filter );
+ }
+
+ $this->assertSame( 1, $this->get_quantity_to_add( $markup ), 'Sold-individually products should still add one item.' );
+ $this->assertStringContainsString( '>Add to cart</span>', $markup, 'The button should not promise a bulk quantity for sold-individually products.' );
+ }
+
+ /**
+ * @testdox Quantity filters keep Product Button quantities, text, accessible labels, and links consistent.
+ * @testWith [true]
+ * [false]
+ * @param bool $ajax_enabled Whether AJAX is enabled.
+ */
+ public function test_loop_add_to_cart_args_sets_initial_quantity( bool $ajax_enabled ): void {
+ update_option( 'woocommerce_enable_ajax_add_to_cart', $ajax_enabled ? 'yes' : 'no' );
+ $product = WC_Helper_Product::create_simple_product();
+ $minimum = static fn() => 10;
+ $filter = static function ( $args ) {
+ $args['quantity'] = 30;
return $args;
};
+ add_filter( 'woocommerce_quantity_input_min', $minimum );
add_filter( 'woocommerce_loop_add_to_cart_args', $filter );
try {
$markup = $this->render_product_button( $product );
} finally {
+ remove_filter( 'woocommerce_quantity_input_min', $minimum );
remove_filter( 'woocommerce_loop_add_to_cart_args', $filter );
}
- $this->assertSame( 4, $this->get_quantity_to_add( $markup ) );
+ $this->assertSame( 30, $this->get_quantity_to_add( $markup ) );
+ $this->assertStringContainsString( '>Add 30 to cart</span>', $markup );
+ $this->assertStringContainsString( 'aria-label="Add 30 to cart:', $markup );
+ if ( ! $ajax_enabled ) {
+ $this->assertStringContainsString( 'quantity=30', $markup );
+ }
}
/**
@@ -259,4 +325,97 @@ class ProductButton extends \WP_UnitTestCase {
return $context['quantityToAdd'];
}
+
+ /**
+ * @testdox Catalog blocks retain URL-filter quantities unless button arguments override them.
+ */
+ public function test_catalog_blocks_retain_filtered_url_quantity(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $minimum = static fn() => 10;
+ $url_filter = static fn( $url ) => add_query_arg( 'quantity', 30, $url );
+ $args_filter = static function ( $args ) {
+ $args['quantity'] = 10;
+ return $args;
+ };
+ add_filter( 'woocommerce_quantity_input_min', $minimum );
+ add_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+ try {
+ $markup = $this->render_product_button( $product );
+ $this->assertSame( 30, $this->get_quantity_to_add( $markup ) );
+ $this->assertStringContainsString( '>Add 30 to cart</span>', $markup );
+ $this->assertStringContainsString( 'aria-label="Add 30 to cart:', $markup );
+ $this->assertStringContainsString( 'quantity=30', $markup );
+
+ $grid = do_blocks( '<!-- wp:woocommerce/handpicked-products {"products":[' . $product->get_id() . ']} /-->' );
+ $this->assertStringContainsString( 'data-quantity="30"', $grid );
+ $this->assertStringContainsString( '>Add 30 to cart</a>', $grid );
+ $this->assertStringContainsString( 'quantity=30', $grid );
+
+ add_filter( 'woocommerce_loop_add_to_cart_args', $args_filter );
+ $markup = $this->render_product_button( $product );
+ $this->assertSame( 10, $this->get_quantity_to_add( $markup ) );
+ $this->assertStringContainsString( '>Add 10 to cart</span>', $markup );
+ $this->assertStringContainsString( 'quantity=10', $markup );
+ } finally {
+ remove_filter( 'woocommerce_quantity_input_min', $minimum );
+ remove_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+ remove_filter( 'woocommerce_loop_add_to_cart_args', $args_filter );
+ }
+ }
+
+ /**
+ * @testdox Buttons inside quantity forms retain their quantity-independent accessible name.
+ */
+ public function test_form_button_retains_description(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $minimum = static fn() => 10;
+ add_filter( 'woocommerce_quantity_input_min', $minimum );
+ try {
+ $block = new \WP_Block(
+ array(
+ 'blockName' => 'woocommerce/product-button',
+ 'attrs' => array(),
+ ),
+ array(
+ 'postId' => $product->get_id(),
+ 'woocommerce/isDescendantOfAddToCartWithOptions' => true,
+ )
+ );
+ $markup = $block->render();
+ } finally {
+ remove_filter( 'woocommerce_quantity_input_min', $minimum );
+ }
+ $this->assertStringContainsString( '>Add to cart</span>', $markup );
+ $this->assertStringContainsString( 'Add to cart:', $this->get_button_aria_label( $markup ) );
+ $this->assertStringNotContainsString( 'Add 10 to cart', $markup );
+ }
+
+ /**
+ * @testdox Legacy grids use their final quantity in both AJAX data and the button label and link.
+ * @testWith [10]
+ * [30]
+ * @param int $quantity Final grid quantity.
+ */
+ public function test_legacy_grid_quantity( int $quantity ): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $minimum = static fn() => 10;
+ $filter = static function ( $attributes ) use ( $quantity ) {
+ $attributes['data-quantity'] = $quantity;
+ return $attributes;
+ };
+ add_filter( 'woocommerce_quantity_input_min', $minimum );
+ if ( 30 === $quantity ) {
+ add_filter( 'woocommerce_blocks_product_grid_add_to_cart_attributes', $filter );
+ }
+ try {
+ $markup = do_blocks( '<!-- wp:woocommerce/handpicked-products {"products":[' . $product->get_id() . ']} /-->' );
+ } finally {
+ remove_filter( 'woocommerce_quantity_input_min', $minimum );
+ remove_filter( 'woocommerce_blocks_product_grid_add_to_cart_attributes', $filter );
+ }
+ $this->assertStringContainsString( 'data-quantity="' . $quantity . '"', $markup );
+ $this->assertStringContainsString( '>Add ' . $quantity . ' to cart</a>', $markup );
+ $this->assertStringContainsString( 'quantity=' . $quantity, $markup );
+ $this->assertStringContainsString( 'aria-label="Add ' . $quantity . ' to cart:', $markup );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
index 887befc08b0..4d097feb01a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
@@ -17,6 +17,50 @@ use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
* Tests for the internal ProductUtil class.
*/
class ProductUtilTest extends \WC_Unit_Test_Case {
+ /**
+ * @testdox Filtered URL quantities respect explicit button quantities and sold-individually limits.
+ * @testWith [null, 30]
+ * [10, 10]
+ * [30, 30]
+ * [null, 1, true]
+ * @param int|null $quantity Explicit button quantity.
+ * @param int $expected Expected quantity.
+ * @param bool $sold_individually Whether the product is sold individually.
+ */
+ public function test_catalog_button_preserves_filtered_url_quantity( ?int $quantity, int $expected, bool $sold_individually = false ): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->set_sold_individually( $sold_individually );
+ $sut = wc_get_container()->get( ProductUtil::class );
+ $minimum = static fn() => 10;
+ $url_filter = static fn( $url ) => add_query_arg(
+ array(
+ 'quantity' => '030',
+ 'custom' => 'a b',
+ ),
+ $url
+ );
+ add_filter( 'woocommerce_quantity_input_min', $minimum );
+ add_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+ try {
+ $url = $product->add_to_cart_url();
+ $data = $sut->get_catalog_add_to_cart_button_data( $product, $quantity );
+ } finally {
+ remove_filter( 'woocommerce_quantity_input_min', $minimum );
+ remove_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+ }
+ $this->assertSame( $expected, $data['quantity'] );
+ $expected_text = $expected > 1 ? 'Add ' . $expected . ' to cart' : 'Add to cart';
+ $this->assertSame( $expected_text, $data['text'] );
+ $this->assertStringContainsString( $expected_text . ':', $data['description'] );
+ if ( 30 === $expected ) {
+ $this->assertSame( $url, $data['url'], 'An unchanged quantity must preserve the filtered URL exactly.' );
+ } else {
+ parse_str( wp_parse_url( $data['url'], PHP_URL_QUERY ), $query );
+ $this->assertSame( (string) $expected, $query['quantity'] );
+ $this->assertSame( 'a b', $query['custom'], 'Other URL customizations must remain intact.' );
+ }
+ }
+
/**
* @testdox Product cache cleanup can be scheduled when the loaded Action Scheduler lacks async actions.
*/