Commit 920f926a761 for woocommerce
commit 920f926a761aa999edd5e6400592ddc45bcb9dcd
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Sat May 16 01:08:21 2026 +0300
Support per-variation reviews on the Review Order page (#64984)
* Skip review-request email when nothing is reviewable
Gate scheduling and sending on ItemEligibility::has_actionable_items()
so the customer never receives an email that would land on the
empty-state page (all products have reviews disabled per-product or
site-wide, or every reviewable item is already reviewed on this order).
The send-time check covers eligibility loss between scheduling and the
delayed send.
* Document empty-state fallback for direct-URL visits
* Add changefile(s) from automation for the following project(s): woocommerce
* Document woocommerce_review_order_eligible_items at has_actionable_items call site
* Support per-variation reviews on the Review Order page
Render one row per line item even when rows share a parent product, so
the customer can leave distinct ratings for each variation they bought.
Each submitted review is tagged with _review_variation_id and a
_review_variation_summary snapshot so the variation context survives on
the parent product's Reviews tab.
ItemEligibility's per-request cache and find_existing_review now key by
variation id alongside product id; preload_for_items emits a slot per
(product, variation) tuple. SubmissionHandler captures the formatted
attribute summary at write time. The single-product Reviews template
gets the snapshot rendered above the comment body via the existing
woocommerce_review_before_comment_text action.
* Scope completion counting and variation snapshots to the current order
Two fixes called out in Copilot review of WOOPLUG-6705 PR #64984:
- maybe_mark_order_complete() now filters reviews by _review_order_id so
older reviews of the same parent product from a different order can't
inflate the current order's per-row review count and falsely stamp
_wc_review_request_completed_at.
- format_variation_summary() and the row template now read the order
line item's stored attribute meta instead of wc_get_formatted_variation
on the live WC_Product_Variation, so the snapshot reflects what the
customer actually bought and is immune to catalog edits between
purchase and review submission.
* Add changefile(s) from automation for the following project(s): woocommerce
* Whitelist variation attributes and count per-slot for completion
Two Copilot pass-2 findings on PR #64984:
- format_variation_summary() and the row template's summary builder both
walked every visible order-item meta entry, which would leak
personalisation / add-on / engraving meta into _review_variation_summary
(and onto the parent product's public Reviews tab). They now whitelist
the variation's own attribute slugs and resolve them against the line
item's stored meta, so the snapshot is restricted to actual attribute
data and stays accurate even when the catalog is edited.
- maybe_mark_order_complete() counted raw comments per parent product, so
a double-submitted variation row could leave two comments behind and
satisfy a sibling variation's quota. It now counts distinct
(parent_id, variation_id) slots so duplicate comments for the same row
contribute exactly once, and a sibling row still requires its own
review.
Tests:
- Updated test_does_not_mark_complete_when_prior_order_review_exists_for_same_parent
to use two real variations of one parent product (the dominant N>1 case).
- New test_duplicate_comments_for_same_slot_do_not_complete_siblings.
* Move rating-required error under the product title
The error message was appended inside the rating cell, which on
narrow viewports (where the row stacks vertically per the @media
breakpoint) can render below the textarea — far from where the
customer's attention is when they submit. Anchoring it right after the
title puts the message at the top of the row so it lands in view
regardless of viewport width, while the rating cell visually hints at
which control needs attention.
* Add bottom margin to rating-required error so the row controls below it aren't crowded
* Address Ferdev PR review on variants PR
- Move format_variation_summary to ItemEligibility as a public static
helper so the row template and SubmissionHandler share one source of
truth for the snapshot.
- Per-row submission result canonicalises product_id to the parent
product and surfaces variation_id separately so callers don't have to
reverse-engineer the line item's shape from whatever the client
posted.
- order-review.js falls back to prepending the rating-required error
into the row when a theme override removes the title element, so the
error is never silently dropped.
- Tests:
- Variation submission test now posts the variation id as product_id
(matching what the page actually sends), asserts product_id +
variation_id in the result, compares the snapshot meta to the
helper's output verbatim, and verifies the order completion meta is
stamped when every slot has a current-order review.
- decide() variation scoping test runs preload_for_items() first so
it exercises the same path the page-template invokes.
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/64984-wooplug-6705-support-per-variation-reviews-on-the-review-order-page b/plugins/woocommerce/changelog/64984-wooplug-6705-support-per-variation-reviews-on-the-review-order-page
new file mode 100644
index 00000000000..81e813534d1
--- /dev/null
+++ b/plugins/woocommerce/changelog/64984-wooplug-6705-support-per-variation-reviews-on-the-review-order-page
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Customer Review Request: render one row per line item on the Review Order page so customers can leave distinct reviews for each variation of a variable product they bought, and surface the variation's attribute summary on the parent product's Reviews tab.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/legacy/css/order-review.scss b/plugins/woocommerce/client/legacy/css/order-review.scss
index 6f0b1f6842d..beefc0d29ed 100644
--- a/plugins/woocommerce/client/legacy/css/order-review.scss
+++ b/plugins/woocommerce/client/legacy/css/order-review.scss
@@ -39,6 +39,14 @@
}
}
+ &__item-variation {
+ display: block;
+ margin-top: 0.25em;
+ font-size: 0.875em;
+ font-weight: 400;
+ opacity: 0.75;
+ }
+
&__item-row {
display: flex;
gap: 1.5em;
@@ -80,7 +88,7 @@
}
&__item-rating-error {
- margin: 0.5em 0 0;
+ margin: 0.5em 0 1em;
color: var(--wc-red, #a00);
}
diff --git a/plugins/woocommerce/client/legacy/js/frontend/order-review.js b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
index 45cf9ad672d..852dd89affa 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/order-review.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
@@ -155,13 +155,7 @@
* @param {boolean} visible Whether the error should be shown.
*/
function setRowRatingError( row, visible ) {
- var rating = row.querySelector(
- '.woocommerce-review-order__item-rating'
- );
- if ( ! rating ) {
- return;
- }
- var existing = rating.querySelector( '.' + ERROR_CLASS );
+ var existing = row.querySelector( ':scope > .' + ERROR_CLASS );
if ( ! visible ) {
if ( existing ) {
existing.parentNode.removeChild( existing );
@@ -179,7 +173,20 @@
note.className = ERROR_CLASS;
note.setAttribute( 'role', 'alert' );
note.textContent = msg;
- rating.appendChild( note );
+
+ // Anchor the error directly under the product title when the row has
+ // one so the customer sees the message at the top of the row, not
+ // buried below the stars on tall layouts. Fall back to prepending
+ // into the row itself so the error is never silently dropped if a
+ // theme override removes the title element.
+ var title = row.querySelector(
+ '.woocommerce-review-order__item-title'
+ );
+ if ( title ) {
+ title.parentNode.insertBefore( note, title.nextSibling );
+ } else {
+ row.insertBefore( note, row.firstChild );
+ }
}
/**
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
index c84acd07cf2..c2d8dc67791 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
@@ -55,10 +55,33 @@ class ItemEligibility {
public const ORDER_META_KEY = '_review_order_id';
/**
- * Per-request cache for the "did this email review this product on this
- * order" lookup, keyed by `order_id|product_id|email`. Value is a
- * `WP_Comment` when one matches, or `null` when the slot has been checked
- * and nothing matches (so a second call doesn't re-query).
+ * Commentmeta key storing the variation id this review was submitted for.
+ *
+ * Always present on reviews written through the Review Order page. Simple
+ * products store `0`. Lets variable-product orders distinguish "Small" from
+ * "Medium" rows that share a parent product.
+ *
+ * @since 10.9.0
+ */
+ public const VARIATION_META_KEY = '_review_variation_id';
+
+ /**
+ * Commentmeta key storing a snapshot of the variation's attribute summary
+ * (e.g. `"Size: Small, Colour: Red"`) at the moment the review was written.
+ *
+ * Captured at write time so historical reviews stay readable even if the
+ * variation is later retired or its attribute taxonomies change.
+ *
+ * @since 10.9.0
+ */
+ public const VARIATION_SUMMARY_META_KEY = '_review_variation_summary';
+
+ /**
+ * Per-request cache for the "did this email review this product (and this
+ * variation) on this order" lookup, keyed by
+ * `order_id|product_id|variation_id|email`. Value is a `WP_Comment` when
+ * one matches, or `null` when the slot has been checked and nothing
+ * matches (so a second call doesn't re-query).
*
* @var array<string, ?WP_Comment>
*/
@@ -87,6 +110,37 @@ class ItemEligibility {
10,
2
);
+
+ // Surface the variation summary captured at submission time on the
+ // single-product Reviews tab, so a review for "Size: Small" doesn't
+ // render indistinguishably from one for "Size: Medium" on the parent
+ // product page.
+ add_action(
+ 'woocommerce_review_before_comment_text',
+ array( self::class, 'render_variation_summary' )
+ );
+ }
+
+ /**
+ * Echo the variation summary snapshot for a review comment, when present.
+ *
+ * Wired onto `woocommerce_review_before_comment_text` so the snapshot
+ * stored in `_review_variation_summary` (set by the Customer Review
+ * Request submission flow) appears immediately above the review body on
+ * the single-product Reviews tab. Comments without the meta render
+ * unchanged.
+ *
+ * @since 10.9.0
+ *
+ * @param \WP_Comment $comment Review comment being rendered.
+ */
+ public static function render_variation_summary( \WP_Comment $comment ): void {
+ $summary = (string) get_comment_meta( (int) $comment->comment_ID, self::VARIATION_SUMMARY_META_KEY, true );
+ if ( '' === $summary ) {
+ return;
+ }
+
+ echo '<p class="woocommerce-review__variation-summary">' . esc_html( $summary ) . '</p>';
}
/**
@@ -114,11 +168,14 @@ class ItemEligibility {
}
$product_ids = array();
+ $slots = array();
foreach ( $items as $item ) {
if ( $item instanceof WC_Order_Item_Product ) {
$pid = (int) $item->get_product_id();
+ $vid = (int) $item->get_variation_id();
if ( $pid > 0 ) {
- $product_ids[ $pid ] = $pid;
+ $product_ids[ $pid ] = $pid;
+ $slots[ self::cache_key( $order_id, $pid, $vid, $email ) ] = true;
}
}
}
@@ -129,6 +186,11 @@ class ItemEligibility {
self::$preloaded[ $preload_key ] = true;
+ // Default every (product, variation) slot to null so subsequent reads don't re-query.
+ foreach ( $slots as $slot_key => $_ ) {
+ self::$review_cache[ $slot_key ] = null;
+ }
+
// Scope to this order's reviews only: a customer who buys the same
// product on a later order shouldn't see their old review here.
$comments = get_comments(
@@ -149,18 +211,14 @@ class ItemEligibility {
)
);
- // Default every product id to null so subsequent reads don't re-query.
- foreach ( $product_ids as $pid ) {
- self::$review_cache[ self::cache_key( $order_id, $pid, $email ) ] = null;
- }
-
if ( is_array( $comments ) ) {
foreach ( $comments as $comment ) {
if ( ! $comment instanceof WP_Comment ) {
continue;
}
- $key = self::cache_key( $order_id, (int) $comment->comment_post_ID, $email );
- if ( null === ( self::$review_cache[ $key ] ?? null ) ) {
+ $vid = (int) get_comment_meta( (int) $comment->comment_ID, self::VARIATION_META_KEY, true );
+ $key = self::cache_key( $order_id, (int) $comment->comment_post_ID, $vid, $email );
+ if ( isset( $slots[ $key ] ) && null === self::$review_cache[ $key ] ) {
self::$review_cache[ $key ] = $comment;
}
}
@@ -188,14 +246,16 @@ class ItemEligibility {
*
* @param WC_Order_Item_Product $item Order line item.
* @param WC_Order $order Order being reviewed.
- * @return array{status:string, comment:?WP_Comment, product_id:int}
+ * @return array{status:string, comment:?WP_Comment, product_id:int, variation_id:int}
*/
public static function decide( WC_Order_Item_Product $item, WC_Order $order ): array {
- $product_id = (int) $item->get_product_id();
- $result = array(
- 'status' => self::STATUS_FORM,
- 'comment' => null,
- 'product_id' => $product_id,
+ $product_id = (int) $item->get_product_id();
+ $variation_id = (int) $item->get_variation_id();
+ $result = array(
+ 'status' => self::STATUS_FORM,
+ 'comment' => null,
+ 'product_id' => $product_id,
+ 'variation_id' => $variation_id,
);
if ( $product_id <= 0 || ! comments_open( $product_id ) ) {
@@ -203,7 +263,7 @@ class ItemEligibility {
return $result;
}
- $result['comment'] = self::find_existing_review( $product_id, $order );
+ $result['comment'] = self::find_existing_review( $product_id, $variation_id, $order );
return $result;
}
@@ -220,7 +280,11 @@ class ItemEligibility {
* @return array{rating:int, text:string, comment_id:int}
*/
public static function prefill_for_item( WC_Order_Item_Product $item, WC_Order $order ): array {
- $existing = self::find_existing_review( (int) $item->get_product_id(), $order );
+ $existing = self::find_existing_review(
+ (int) $item->get_product_id(),
+ (int) $item->get_variation_id(),
+ $order
+ );
if ( ! $existing instanceof WP_Comment ) {
return array(
'rating' => 0,
@@ -241,6 +305,57 @@ class ItemEligibility {
);
}
+ /**
+ * Render the variation's attribute summary as a single flat line.
+ *
+ * Used both at write time (snapshotted into `_review_variation_summary`)
+ * and at render time by the Review Order row template, so the two places
+ * always agree on what label the customer sees and what the comment
+ * stores. Restricted to actual variation attribute slugs so personalisation
+ * / add-on / engraving / gift-message meta from third-party plugins isn't
+ * accidentally folded into the public review snapshot. Returns an empty
+ * string for simple products or when the variation product can no longer
+ * be loaded to identify its attribute slugs.
+ *
+ * Keys in the line item meta are stored without the `attribute_` prefix
+ * (see `WC_Order_Item_Product::set_variation()`), so we strip the prefix
+ * from the live variation's attribute keys to match.
+ *
+ * @since 10.9.0
+ *
+ * @param WC_Order_Item_Product $item Order line item.
+ */
+ public static function format_variation_summary( WC_Order_Item_Product $item ): string {
+ $variation_id = (int) $item->get_variation_id();
+ if ( $variation_id <= 0 ) {
+ return '';
+ }
+
+ $variation = wc_get_product( $variation_id );
+ if ( ! $variation instanceof \WC_Product_Variation ) {
+ return '';
+ }
+
+ $attributes = array();
+ foreach ( array_keys( (array) $variation->get_variation_attributes() ) as $attribute_key ) {
+ $slug = str_replace( 'attribute_', '', (string) $attribute_key );
+ if ( '' === $slug ) {
+ continue;
+ }
+ $value = $item->get_meta( $slug, true );
+ if ( '' === $value || null === $value ) {
+ continue;
+ }
+ $attributes[ $slug ] = $value;
+ }
+
+ if ( empty( $attributes ) ) {
+ return '';
+ }
+
+ return (string) wc_get_formatted_variation( $attributes, true );
+ }
+
/**
* Whether an order has at least one item the customer can still review.
*
@@ -328,22 +443,24 @@ class ItemEligibility {
}
/**
- * Look up the customer's review for a product on this order.
+ * Look up the customer's review for a specific (product, variation) row on
+ * this order.
*
* @since 10.8.0
*
- * @param int $product_id Product id.
- * @param WC_Order $order Order being reviewed.
+ * @param int $product_id Product id.
+ * @param int $variation_id Variation id (0 for simple products).
+ * @param WC_Order $order Order being reviewed.
* @return WP_Comment|null
*/
- private static function find_existing_review( int $product_id, WC_Order $order ): ?WP_Comment {
+ private static function find_existing_review( int $product_id, int $variation_id, WC_Order $order ): ?WP_Comment {
$email = $order->get_billing_email();
$order_id = (int) $order->get_id();
if ( '' === $email || $order_id <= 0 || $product_id <= 0 ) {
return null;
}
- $key = self::cache_key( $order_id, $product_id, $email );
+ $key = self::cache_key( $order_id, $product_id, $variation_id, $email );
if ( array_key_exists( $key, self::$review_cache ) ) {
return self::$review_cache[ $key ];
}
@@ -359,10 +476,15 @@ class ItemEligibility {
'orderby' => 'comment_date_gmt',
'order' => 'DESC',
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post_id + author_email.
+ 'relation' => 'AND',
array(
'key' => self::ORDER_META_KEY,
'value' => (string) $order_id,
),
+ array(
+ 'key' => self::VARIATION_META_KEY,
+ 'value' => (string) $variation_id,
+ ),
),
)
);
@@ -382,11 +504,12 @@ class ItemEligibility {
/**
* Build the per-request cache key.
*
- * @param int $order_id Order id.
- * @param int $product_id Product id.
- * @param string $email Customer email.
+ * @param int $order_id Order id.
+ * @param int $product_id Product id.
+ * @param int $variation_id Variation id (0 for simple products).
+ * @param string $email Customer email.
*/
- private static function cache_key( int $order_id, int $product_id, string $email ): string {
- return $order_id . '|' . $product_id . '|' . $email;
+ private static function cache_key( int $order_id, int $product_id, int $variation_id, string $email ): string {
+ return $order_id . '|' . $product_id . '|' . $variation_id . '|' . $email;
}
}
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
index 37b46c0a2ce..16da25f6e05 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
@@ -152,9 +152,16 @@ class SubmissionHandler {
// $rows_in was already unslashed in handle(); avoid double-unslashing.
$text = isset( $row['text'] ) && is_string( $row['text'] ) ? trim( wp_kses_post( $row['text'] ) ) : '';
+ // Per-row result always carries `product_id` (parent product, where
+ // the review lives) and `variation_id` (0 for simple products) so
+ // callers don't have to know whether the client posted the parent
+ // or the variation id as `product_id`. Both are echoed back as
+ // soon as we resolve the line item; for early validation failures
+ // they reflect the raw submitted product id with `variation_id: 0`.
$result = array(
- 'product_id' => $product_id,
- 'status' => 'error',
+ 'product_id' => $product_id,
+ 'variation_id' => 0,
+ 'status' => 'error',
);
if ( $rating < 1 || $rating > 5 ) {
@@ -183,6 +190,11 @@ class SubmissionHandler {
continue;
}
+ // Canonicalise the result fields now that we've resolved the line
+ // item: parent product id + the line's variation id (0 for simple).
+ $result['product_id'] = $line_product_id;
+ $result['variation_id'] = $line_variation_id;
+
// Reviews always attach to the parent product so they show on the
// product page regardless of which variation was bought.
$review_post_id = $line_product_id;
@@ -255,6 +267,12 @@ class SubmissionHandler {
add_comment_meta( $comment_id, 'rating', $rating, true );
add_comment_meta( $comment_id, 'verified', 1, true );
add_comment_meta( $comment_id, ItemEligibility::ORDER_META_KEY, (int) $order->get_id(), true );
+ add_comment_meta( $comment_id, ItemEligibility::VARIATION_META_KEY, $line_variation_id, true );
+
+ $variation_summary = ItemEligibility::format_variation_summary( $item );
+ if ( '' !== $variation_summary ) {
+ add_comment_meta( $comment_id, ItemEligibility::VARIATION_SUMMARY_META_KEY, $variation_summary, true );
+ }
$result['comment_id'] = (int) $comment_id;
$result['status'] = $require_mod ? 'pending_moderation' : 'ok';
@@ -282,39 +300,54 @@ class SubmissionHandler {
return;
}
- // Build the same eligible-row set the page uses, then count required
- // reviews per parent product. Same product appearing on N rows needs
- // N reviews, not 1.
+ // Build the same eligible-row set the page uses, then collect the
+ // distinct (parent product, variation) slots that need a review.
+ // Counting by slot rather than per-line-item means a double-submit of
+ // the same variation can't satisfy a sibling variation's quota, and
+ // the same simple product appearing on multiple rows still only
+ // needs one review (the page collapses those rows anyway).
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented at the page-template invocation site.
$eligible_items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
- $required_reviews = array();
+ $required_slots = array();
+ $product_ids = array();
foreach ( $eligible_items as $item ) {
if ( ! $item instanceof \WC_Order_Item_Product ) {
continue;
}
- $product_id = (int) $item->get_product_id();
+ $product_id = (int) $item->get_product_id();
+ $variation_id = (int) $item->get_variation_id();
if ( $product_id > 0 ) {
- $required_reviews[ $product_id ] = ( $required_reviews[ $product_id ] ?? 0 ) + 1;
+ $required_slots[ $product_id . '|' . $variation_id ] = true;
+ $product_ids[ $product_id ] = $product_id;
}
}
- if ( empty( $required_reviews ) ) {
+ if ( empty( $required_slots ) ) {
return;
}
// Single grouped lookup, fetching the comment objects directly so we
// can read comment_post_ID without a follow-up query per row. Limit
// to approved + pending-moderation so spam/trash never count as
- // completion. number=>0 disables the default 20-row cap so this still
- // works for orders with many reviewable items.
+ // completion, AND to reviews tagged with this order so an older
+ // review of the same parent product from a previous order doesn't
+ // satisfy the per-row count for the current one. number=>0 disables
+ // the default 20-row cap so this still works for orders with many
+ // reviewable items.
$comments = get_comments(
array(
- 'post__in' => array_keys( $required_reviews ),
+ 'post__in' => array_values( $product_ids ),
'author_email' => $customer_email,
'type' => 'review',
'status' => array( 'approve', 'hold' ),
'number' => 0,
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post__in + author_email.
+ array(
+ 'key' => ItemEligibility::ORDER_META_KEY,
+ 'value' => (string) $order->get_id(),
+ ),
+ ),
)
);
@@ -322,16 +355,18 @@ class SubmissionHandler {
return;
}
- $review_counts = array();
+ // Index reviewed slots by (parent_id, variation_id); duplicate comments
+ // for the same slot still count as one toward completion.
+ $reviewed_slots = array();
foreach ( $comments as $comment ) {
if ( $comment instanceof \WP_Comment ) {
- $post_id = (int) $comment->comment_post_ID;
- $review_counts[ $post_id ] = ( $review_counts[ $post_id ] ?? 0 ) + 1;
+ $slot_key = (int) $comment->comment_post_ID . '|' . (int) get_comment_meta( (int) $comment->comment_ID, ItemEligibility::VARIATION_META_KEY, true );
+ $reviewed_slots[ $slot_key ] = true;
}
}
- foreach ( $required_reviews as $product_id => $required ) {
- if ( ( $review_counts[ $product_id ] ?? 0 ) < $required ) {
+ foreach ( $required_slots as $slot_key => $_ ) {
+ if ( ! isset( $reviewed_slots[ $slot_key ] ) ) {
return;
}
}
diff --git a/plugins/woocommerce/templates/order/customer-review-order-row.php b/plugins/woocommerce/templates/order/customer-review-order-row.php
index ef420b2281c..b6a2927377e 100644
--- a/plugins/woocommerce/templates/order/customer-review-order-row.php
+++ b/plugins/woocommerce/templates/order/customer-review-order-row.php
@@ -6,7 +6,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 10.8.0
+ * @version 10.9.0
*
* @var WC_Order_Item_Product $item Order line item being rendered.
* @var WC_Product $product Product attached to the line item.
@@ -25,11 +25,16 @@ if ( ! $item instanceof WC_Order_Item_Product || ! $product instanceof WC_Produc
$existing_rating = isset( $existing_rating ) ? (int) $existing_rating : 0;
$existing_text = isset( $existing_text ) ? (string) $existing_text : '';
-$item_id = $item->get_id();
-$product_id = $product->get_id();
-$product_link = $product->is_visible() ? get_permalink( $product_id ) : '';
-$product_name = $item->get_name();
-$image_html = $product->get_image( 'woocommerce_thumbnail' );
+$item_id = $item->get_id();
+$product_id = $product->get_id();
+$product_link = $product->is_visible() ? get_permalink( $product_id ) : '';
+$product_name = $item->get_name();
+$image_html = $product->get_image( 'woocommerce_thumbnail' );
+
+// Variation attribute summary (e.g. "Size: Small, Colour: Red"). Empty for simple products.
+// Shared with SubmissionHandler so the snapshot stored on the comment matches the label rendered here.
+$variation_summary = \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::format_variation_summary( $item );
+
$rating_label_id = 'woocommerce-review-rating-label-' . $item_id;
$review_label_id = 'woocommerce-review-text-label-' . $item_id;
$review_input_id = 'woocommerce-review-text-' . $item_id;
@@ -58,6 +63,9 @@ $rating_control = \Automattic\WooCommerce\Internal\OrderReviews\StarRating::rend
<?php else : ?>
<?php echo esc_html( $product_name ); ?>
<?php endif; ?>
+ <?php if ( '' !== $variation_summary ) : ?>
+ <span class="woocommerce-review-order__item-variation"><?php echo esc_html( $variation_summary ); ?></span>
+ <?php endif; ?>
</p>
<div class="woocommerce-review-order__item-row">
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
index d1c1895fc16..a60a369ebc8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
@@ -63,17 +63,19 @@ class ItemEligibilityTest extends WC_Unit_Test_Case {
}
/**
- * Insert a customer review for a product, optionally tagged with the source order id.
+ * Insert a customer review for a product, tagged with the source order
+ * and (optionally) variation id.
*
- * @param int $product_id Product post id.
- * @param string $email Author email.
- * @param string $body Comment body.
- * @param int $rating Rating value 1-5.
- * @param int|null $order_id Source order id stamped as `_review_order_id` commentmeta. Pass null to skip.
- * @param int $approved 1 for approved, 0 for pending moderation.
+ * @param int $product_id Product post id.
+ * @param string $email Author email.
+ * @param string $body Comment body.
+ * @param int $rating Rating value 1-5.
+ * @param int|null $order_id Source order id stamped as `_review_order_id` commentmeta. Pass null to skip.
+ * @param int $variation_id Variation id stamped as `_review_variation_id` commentmeta. 0 for simple products.
+ * @param int $approved 1 for approved, 0 for pending moderation.
* @return int Inserted comment id.
*/
- private function insert_review( int $product_id, string $email, string $body, int $rating, ?int $order_id = null, int $approved = 1 ): int {
+ private function insert_review( int $product_id, string $email, string $body, int $rating, ?int $order_id = null, int $variation_id = 0, int $approved = 1 ): int {
$comment_id = (int) wp_insert_comment(
array(
'comment_post_ID' => $product_id,
@@ -88,6 +90,7 @@ class ItemEligibilityTest extends WC_Unit_Test_Case {
if ( null !== $order_id ) {
add_comment_meta( $comment_id, ItemEligibility::ORDER_META_KEY, $order_id, true );
}
+ add_comment_meta( $comment_id, ItemEligibility::VARIATION_META_KEY, $variation_id, true );
return $comment_id;
}
@@ -328,4 +331,73 @@ class ItemEligibilityTest extends WC_Unit_Test_Case {
$this->assertFalse( ItemEligibility::has_actionable_items( $built['order'] ) );
}
+
+ /**
+ * @testdox decide() scopes the existing-review lookup by variation id.
+ *
+ * Two variation rows of the same parent product on the same order: a
+ * review tagged with variation A's id must prefill only the row whose
+ * line item is variation A. The variation B row stays unreviewed.
+ */
+ public function test_decide_scopes_by_variation_id(): void {
+ $built = $this->make_variation_order( 'shopper@example.test' );
+
+ $this->insert_review(
+ $built['product_id'],
+ 'shopper@example.test',
+ 'Loved the Small.',
+ 5,
+ (int) $built['order']->get_id(),
+ $built['variation_a_id']
+ );
+
+ // Mirror the page-load path: bulk preload, then per-item decide(). This
+ // exercises the preload bucketing logic as well, not just the
+ // fallback single-item query inside `find_existing_review()`.
+ $items = $built['order']->get_items();
+ ItemEligibility::preload_for_items( $items, $built['order'] );
+
+ $decision_a = ItemEligibility::decide( $built['item_a'], $built['order'] );
+ $decision_b = ItemEligibility::decide( $built['item_b'], $built['order'] );
+
+ $this->assertNotNull( $decision_a['comment'], 'Variation A row should prefill from its own review.' );
+ $this->assertNull( $decision_b['comment'], 'Variation B row should stay unreviewed.' );
+ $this->assertSame( $built['variation_a_id'], $decision_a['variation_id'] );
+ $this->assertSame( $built['variation_b_id'], $decision_b['variation_id'] );
+ }
+
+ /**
+ * Build a completed order with two variations of one parent variable product.
+ *
+ * @param string $email Billing email to set on the order.
+ * @return array{order:\WC_Order, item_a:\WC_Order_Item_Product, item_b:\WC_Order_Item_Product, product_id:int, variation_a_id:int, variation_b_id:int}
+ */
+ private function make_variation_order( string $email ): array {
+ $variable = WC_Helper_Product::create_variation_product();
+ $variation_ids = $variable->get_children();
+ $variation_a = wc_get_product( $variation_ids[0] );
+ $variation_b = wc_get_product( $variation_ids[1] );
+
+ $order = OrderHelper::create_order();
+ foreach ( $order->get_items() as $line ) {
+ $order->remove_item( $line->get_id() );
+ }
+ $order->set_billing_email( $email );
+ $order->set_status( OrderStatus::COMPLETED );
+
+ $order->add_product( $variation_a, 1 );
+ $order->add_product( $variation_b, 1 );
+ $order->save();
+
+ $items = array_values( $order->get_items() );
+
+ return array(
+ 'order' => $order,
+ 'item_a' => $items[0],
+ 'item_b' => $items[1],
+ 'product_id' => $variable->get_id(),
+ 'variation_a_id' => (int) $variation_a->get_id(),
+ 'variation_b_id' => (int) $variation_b->get_id(),
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
index 56019616ace..f4971d87f37 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
@@ -212,6 +212,98 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
$this->assertSame( 'review', $comment->comment_type );
$this->assertSame( '5', get_comment_meta( $row['comment_id'], 'rating', true ) );
$this->assertSame( '1', get_comment_meta( $row['comment_id'], 'verified', true ) );
+ // Simple products store variation_id `0`. Summary meta is omitted entirely (empty string would be misleading).
+ $this->assertSame( '0', get_comment_meta( $row['comment_id'], ItemEligibility::VARIATION_META_KEY, true ) );
+ $this->assertSame( '', get_comment_meta( $row['comment_id'], ItemEligibility::VARIATION_SUMMARY_META_KEY, true ) );
+ }
+
+ /**
+ * @testdox Two variations of one parent product each produce their own review with variation meta.
+ */
+ public function test_writes_per_variation_meta_for_variable_product_rows(): void {
+ $variable = WC_Helper_Product::create_variation_product();
+ $variation_ids = $variable->get_children();
+ $variation_a = wc_get_product( $variation_ids[0] );
+ $variation_b = wc_get_product( $variation_ids[1] );
+
+ $order = OrderHelper::create_order();
+ foreach ( $order->get_items() as $item ) {
+ $order->remove_item( $item->get_id() );
+ }
+ $order->set_billing_email( 'shopper@example.test' );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->add_product( $variation_a, 1 );
+ $order->add_product( $variation_b, 1 );
+ $order->save();
+
+ $items = array_values( $order->get_items() );
+ $item_a = $items[0];
+ $item_b = $items[1];
+
+ // The page template posts each row's product_id as the *variation* id
+ // (it reads `$product->get_id()` on the WC_Product_Variation), so the
+ // test mirrors that to exercise the same path SubmissionHandler runs
+ // in production.
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $variation_a->get_id(),
+ 'order_item_id' => $item_a->get_id(),
+ 'rating' => 5,
+ 'text' => 'Loved variation A.',
+ ),
+ array(
+ 'product_id' => $variation_b->get_id(),
+ 'order_item_id' => $item_b->get_id(),
+ 'rating' => 3,
+ 'text' => 'Variation B was just OK.',
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $this->assertTrue( $response['success'] );
+ $results = $response['data']['results'];
+ $this->assertCount( 2, $results );
+
+ $rows = array_values( $results );
+ $comment_a_id = (int) $rows[0]['comment_id'];
+ $comment_b_id = (int) $rows[1]['comment_id'];
+ $this->assertNotSame( $comment_a_id, $comment_b_id, 'Each variation row should produce its own comment.' );
+
+ // Result rows canonicalise product_id to the parent and surface the variation_id separately.
+ $this->assertSame( (int) $variable->get_id(), (int) $rows[0]['product_id'] );
+ $this->assertSame( (int) $variable->get_id(), (int) $rows[1]['product_id'] );
+ $this->assertSame( (int) $variation_a->get_id(), (int) $rows[0]['variation_id'] );
+ $this->assertSame( (int) $variation_b->get_id(), (int) $rows[1]['variation_id'] );
+
+ $this->assertSame( (string) $variation_a->get_id(), get_comment_meta( $comment_a_id, ItemEligibility::VARIATION_META_KEY, true ) );
+ $this->assertSame( (string) $variation_b->get_id(), get_comment_meta( $comment_b_id, ItemEligibility::VARIATION_META_KEY, true ) );
+
+ // Exact snapshot text — derived the same way the production helper builds it.
+ $expected_a = ItemEligibility::format_variation_summary( $item_a );
+ $expected_b = ItemEligibility::format_variation_summary( $item_b );
+ $this->assertNotSame( '', $expected_a, 'Test setup precondition: variation A produces a non-empty summary.' );
+ $this->assertNotSame( '', $expected_b, 'Test setup precondition: variation B produces a non-empty summary.' );
+ $this->assertSame(
+ $expected_a,
+ get_comment_meta( $comment_a_id, ItemEligibility::VARIATION_SUMMARY_META_KEY, true )
+ );
+ $this->assertSame(
+ $expected_b,
+ get_comment_meta( $comment_b_id, ItemEligibility::VARIATION_SUMMARY_META_KEY, true )
+ );
+ $this->assertNotSame( $expected_a, $expected_b, 'Each variation should snapshot its own attribute summary.' );
+
+ // Every row reviewed → order completion meta stamped.
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertNotEmpty(
+ $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ),
+ 'When every reviewable slot has a current-order review, the order should be marked complete.'
+ );
}
/**
@@ -437,6 +529,131 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
$this->assertEmpty( $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ) );
}
+ /**
+ * @testdox Completion stamping ignores reviews tagged to a different order, even on the same parent product.
+ *
+ * Per-order scope guards against an older review from a previous order
+ * inflating the current order's review count. Two variation rows on the
+ * current order require two current-order reviews; an older review of
+ * the same parent must not count toward that quota.
+ */
+ public function test_does_not_mark_complete_when_prior_order_review_exists_for_same_parent(): void {
+ $variable = WC_Helper_Product::create_variation_product();
+ $variation_ids = $variable->get_children();
+ $variation_a = wc_get_product( $variation_ids[0] );
+ $variation_b = wc_get_product( $variation_ids[1] );
+ $other_order = OrderHelper::create_order();
+
+ // Pre-existing approved review from an older order, same parent, variation A.
+ $prior_comment_id = (int) wp_insert_comment(
+ array(
+ 'comment_post_ID' => $variable->get_id(),
+ 'comment_author' => 'Jane',
+ 'comment_author_email' => 'jane@example.test',
+ 'comment_content' => 'Reviewed previously.',
+ 'comment_type' => 'review',
+ 'comment_approved' => 1,
+ )
+ );
+ add_comment_meta( $prior_comment_id, 'rating', 4, true );
+ add_comment_meta( $prior_comment_id, ItemEligibility::ORDER_META_KEY, (int) $other_order->get_id(), true );
+ add_comment_meta( $prior_comment_id, ItemEligibility::VARIATION_META_KEY, (int) $variation_a->get_id(), true );
+
+ $order = OrderHelper::create_order();
+ foreach ( $order->get_items() as $item ) {
+ $order->remove_item( $item->get_id() );
+ }
+ $order->set_billing_email( 'jane@example.test' );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->add_product( $variation_a, 1 );
+ $order->add_product( $variation_b, 1 );
+ $order->save();
+
+ $items = array_values( $order->get_items() );
+ $item_a = $items[0];
+
+ // Customer reviews variation A on the CURRENT order. Variation B stays unreviewed.
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $variable->get_id(),
+ 'order_item_id' => $item_a->get_id(),
+ 'rating' => 5,
+ ),
+ ),
+ );
+
+ $this->dispatch();
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertEmpty(
+ $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ),
+ 'Older-order reviews on the same parent product must not inflate the current order\'s per-slot count; variation B is still unreviewed.'
+ );
+ }
+
+ /**
+ * @testdox Duplicate comments for the same variation row do not satisfy a sibling row's quota.
+ *
+ * Guards the per-slot completion count against a double-submit (concurrent
+ * AJAX or client retry) writing two comments for the same variation: the
+ * sibling variation row should still be considered unreviewed.
+ */
+ public function test_duplicate_comments_for_same_slot_do_not_complete_siblings(): void {
+ $variable = WC_Helper_Product::create_variation_product();
+ $variation_ids = $variable->get_children();
+ $variation_a = wc_get_product( $variation_ids[0] );
+ $variation_b = wc_get_product( $variation_ids[1] );
+
+ $order = OrderHelper::create_order();
+ foreach ( $order->get_items() as $item ) {
+ $order->remove_item( $item->get_id() );
+ }
+ $order->set_billing_email( 'jane@example.test' );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->add_product( $variation_a, 1 );
+ $order->add_product( $variation_b, 1 );
+ $order->save();
+
+ $order_id = (int) $order->get_id();
+
+ // Simulate a double-submit by writing two approved comments tagged with variation A.
+ foreach ( array( 'First click.', 'Retry click.' ) as $body ) {
+ $cid = (int) wp_insert_comment(
+ array(
+ 'comment_post_ID' => $variable->get_id(),
+ 'comment_author' => 'Jane',
+ 'comment_author_email' => 'jane@example.test',
+ 'comment_content' => $body,
+ 'comment_type' => 'review',
+ 'comment_approved' => 1,
+ )
+ );
+ add_comment_meta( $cid, 'rating', 5, true );
+ add_comment_meta( $cid, ItemEligibility::ORDER_META_KEY, $order_id, true );
+ add_comment_meta( $cid, ItemEligibility::VARIATION_META_KEY, (int) $variation_a->get_id(), true );
+ }
+
+ // Trigger completion evaluation via an empty submission.
+ $_POST = array(
+ 'order_id' => $order_id,
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(),
+ );
+
+ $this->dispatch();
+
+ $fresh = wc_get_order( $order_id );
+ $this->assertEmpty(
+ $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ),
+ 'Two comments for variation A must not fill variation B\'s slot.'
+ );
+ }
+
/**
* @testdox A successful submission fires the woocommerce_review_order_submitted action with order + per-row results.
*/