Commit ab27598bc57 for woocommerce

commit ab27598bc57660903341ae99967c3981db5ac93b
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Wed Jul 15 15:34:19 2026 +0300

    Add attribute taxonomy row action filter (#66633)

    * feat: add attribute taxonomy row action filter

    Context: the Products > Attributes admin table is a custom WooCommerce screen, so WordPress taxonomy row action filters do not let extensions alter its attribute row actions.

    Problem: the table hardcoded only Edit and Delete links, which forced extensions to replace broader admin output when they needed to add or adjust actions for global product attributes.

    Solution: build the default row actions as escaped link HTML, expose them through woocommerce_attribute_taxonomy_row_actions with the current attribute object and full taxonomy name, and cover default plus filtered rendering with PHP unit tests.

    Refs #31531

    * test(admin): Strengthen attribute row action coverage

    The attribute row-action regression only proved that filters could add actions, and its generic nonce assertion could match an unrelated form nonce.

    Exercise the reported removal use case, verify the exact attribute-specific Delete URL, and rely on the CRUD APIs for cache invalidation. This makes the regression mutation-resistant while reducing test coupling.

    Refs #31531

    * fix: guard attribute row actions filter against non-array returns

diff --git a/plugins/woocommerce/changelog/add-31531-attribute-taxonomy-row-actions b/plugins/woocommerce/changelog/add-31531-attribute-taxonomy-row-actions
new file mode 100644
index 00000000000..4922f7ffd17
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-31531-attribute-taxonomy-row-actions
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a filter for product attribute taxonomy row actions in the Attributes admin table.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-attributes.php b/plugins/woocommerce/includes/admin/class-wc-admin-attributes.php
index 830e22aab78..5670e73ef24 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-attributes.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-attributes.php
@@ -440,12 +440,36 @@ class WC_Admin_Attributes {
 									 */
 									$max_terms_to_display = apply_filters( 'woocommerce_max_terms_displayed_in_attributes_page', 100 );
 									foreach ( $attribute_taxonomies as $tax ) :
+										$taxonomy = wc_attribute_taxonomy_name( $tax->attribute_name );
+										$actions  = array(
+											'edit'   => '<a href="' . esc_url( add_query_arg( 'edit', $tax->attribute_id, 'edit.php?post_type=product&amp;page=product_attributes' ) ) . '">' . esc_html__( 'Edit', 'woocommerce' ) . '</a>',
+											'delete' => '<a class="delete" href="' . esc_url( wp_nonce_url( add_query_arg( 'delete', $tax->attribute_id, 'edit.php?post_type=product&amp;page=product_attributes' ), 'woocommerce-delete-attribute_' . $tax->attribute_id ) ) . '">' . esc_html__( 'Delete', 'woocommerce' ) . '</a>',
+										);
+
+										/**
+										 * Filters the row actions for a global product attribute taxonomy on the Products > Attributes screen.
+										 *
+										 * The action values are HTML links. Default actions are escaped before this filter runs, and callbacks
+										 * should return safe, escaped HTML.
+										 *
+										 * @param array<string,string> $actions  Action link HTML keyed by action name.
+										 * @param object               $tax      Attribute taxonomy object.
+										 * @param string               $taxonomy Full taxonomy name, including the `pa_` prefix.
+										 *
+										 * @since 11.1.0
+										 */
+										$actions     = (array) apply_filters( 'woocommerce_attribute_taxonomy_row_actions', $actions, $tax, $taxonomy );
+										$row_actions = array();
+
+										foreach ( $actions as $action => $link ) {
+											$row_actions[] = '<span class="' . esc_attr( $action ) . '">' . $link . '</span>';
+										}
 										?>
 										<tr>
 												<td>
-													<strong><a href="edit-tags.php?taxonomy=<?php echo esc_attr( wc_attribute_taxonomy_name( $tax->attribute_name ) ); ?>&amp;post_type=product"><?php echo esc_html( $tax->attribute_label ); ?></a></strong>
+													<strong><a href="edit-tags.php?taxonomy=<?php echo esc_attr( $taxonomy ); ?>&amp;post_type=product"><?php echo esc_html( $tax->attribute_label ); ?></a></strong>

-													<div class="row-actions"><span class="edit"><a href="<?php echo esc_url( add_query_arg( 'edit', $tax->attribute_id, 'edit.php?post_type=product&amp;page=product_attributes' ) ); ?>"><?php esc_html_e( 'Edit', 'woocommerce' ); ?></a> | </span><span class="delete"><a class="delete" href="<?php echo esc_url( wp_nonce_url( add_query_arg( 'delete', $tax->attribute_id, 'edit.php?post_type=product&amp;page=product_attributes' ), 'woocommerce-delete-attribute_' . $tax->attribute_id ) ); ?>"><?php esc_html_e( 'Delete', 'woocommerce' ); ?></a></span></div>
+													<?php echo '<div class="row-actions">' . implode( ' | ', $row_actions ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Row action values are link HTML. Default values are escaped above; filtered callbacks should return safe HTML. ?>
 												</td>
 												<td><?php echo esc_html( $tax->attribute_name ); ?></td>
 												<?php if ( wc_has_custom_attribute_types() ) : ?>
@@ -471,8 +495,6 @@ class WC_Admin_Attributes {
 												</td>
 												<td class="attribute-terms">
 													<?php
-													$taxonomy = wc_attribute_taxonomy_name( $tax->attribute_name );
-
 													if ( taxonomy_exists( $taxonomy ) ) {
 														$total_count = (int) get_terms(
 															array(
@@ -509,7 +531,7 @@ class WC_Admin_Attributes {
 															echo '<span class="na">&ndash;</span><br />';
 													}//end if
 													?>
-													<br /><a href="edit-tags.php?taxonomy=<?php echo esc_attr( wc_attribute_taxonomy_name( $tax->attribute_name ) ); ?>&amp;post_type=product" class="configure-terms"><?php esc_html_e( 'Configure terms', 'woocommerce' ); ?></a>
+													<br /><a href="edit-tags.php?taxonomy=<?php echo esc_attr( $taxonomy ); ?>&amp;post_type=product" class="configure-terms"><?php esc_html_e( 'Configure terms', 'woocommerce' ); ?></a>
 												</td>
 											</tr>
 											<?php
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-attributes-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-attributes-test.php
new file mode 100644
index 00000000000..2c22374cc32
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-attributes-test.php
@@ -0,0 +1,151 @@
+<?php
+/**
+ * Tests for WC_Admin_Attributes.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+
+declare( strict_types = 1 );
+
+require_once WC_ABSPATH . '/includes/admin/class-wc-admin-attributes.php';
+
+/**
+ * WC_Admin_Attributes tests.
+ */
+class WC_Admin_Attributes_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Created attribute IDs to remove after each test.
+	 *
+	 * @var int[]
+	 */
+	private array $attribute_ids = array();
+
+	/**
+	 * Test attribute counter for unique slugs.
+	 *
+	 * @var int
+	 */
+	private static int $attribute_counter = 0;
+
+	/**
+	 * Clean up test attributes, filters, and attribute caches.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_attribute_taxonomy_row_actions' );
+
+		foreach ( $this->attribute_ids as $attribute_id ) {
+			wc_delete_attribute( $attribute_id );
+		}
+
+		$this->attribute_ids = array();
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox add_attribute() renders default edit and delete row actions for attribute taxonomies.
+	 *
+	 * @covers WC_Admin_Attributes::add_attribute()
+	 */
+	public function test_add_attribute_renders_default_row_actions(): void {
+		$attribute_id        = $this->create_test_attribute( 'test_attr_default' );
+		$expected_delete_url = wp_nonce_url(
+			add_query_arg( 'delete', $attribute_id, 'edit.php?post_type=product&amp;page=product_attributes' ),
+			'woocommerce-delete-attribute_' . $attribute_id
+		);
+
+		$output         = $this->render_add_attribute_page();
+		$decoded_output = html_entity_decode( $output, ENT_QUOTES );
+
+		$this->assertStringContainsString( '<div class="row-actions">', $output, 'Attribute rows should include row actions.' );
+		$this->assertStringContainsString( '<span class="edit"><a href=', $output, 'The edit action should be rendered as a row action.' );
+		$this->assertStringContainsString( '<span class="delete"><a class="delete" href=', $output, 'The delete action should preserve the delete class.' );
+		$this->assertStringContainsString( 'edit.php?post_type=product&page=product_attributes&edit=' . $attribute_id, $decoded_output, 'The edit action should target the attribute edit screen.' );
+		$this->assertStringContainsString( 'href="' . esc_url( $expected_delete_url ) . '"', $output, 'The delete action should retain its attribute-specific nonce.' );
+	}
+
+	/**
+	 * @testdox add_attribute() filters attribute taxonomy row actions.
+	 *
+	 * @covers WC_Admin_Attributes::add_attribute()
+	 */
+	public function test_add_attribute_filters_attribute_taxonomy_row_actions(): void {
+		$attribute_id      = $this->create_test_attribute( 'test_attr_filter' );
+		$captured_actions  = null;
+		$captured_tax      = null;
+		$captured_taxonomy = null;
+
+		add_filter(
+			'woocommerce_attribute_taxonomy_row_actions',
+			function ( array $actions, object $tax, string $taxonomy ) use ( $attribute_id, &$captured_actions, &$captured_tax, &$captured_taxonomy ): array {
+				if ( $attribute_id !== (int) $tax->attribute_id ) {
+					return $actions;
+				}
+
+				$captured_actions  = $actions;
+				$captured_tax      = $tax;
+				$captured_taxonomy = $taxonomy;
+				unset( $actions['delete'] );
+				$actions['sync'] = '<a href="' . esc_url( 'https://example.test/sync-attribute' ) . '">Sync</a>';
+
+				return $actions;
+			},
+			10,
+			3
+		);
+
+		$output         = $this->render_add_attribute_page();
+		$decoded_output = html_entity_decode( $output, ENT_QUOTES );
+
+		$this->assertIsArray( $captured_actions, 'The row actions filter should run for the test attribute.' );
+		$this->assertArrayHasKey( 'edit', $captured_actions, 'Default edit action should be filterable.' );
+		$this->assertArrayHasKey( 'delete', $captured_actions, 'Default delete action should be filterable.' );
+		$this->assertSame( $attribute_id, (int) $captured_tax->attribute_id, 'The filter should receive the current attribute taxonomy object.' );
+		$this->assertSame( 'pa_test_attr_filter', $captured_taxonomy, 'The filter should receive the full taxonomy name.' );
+		$this->assertStringContainsString(
+			'<span class="sync"><a href="https://example.test/sync-attribute">Sync</a></span>',
+			$output,
+			'Custom filtered actions should render as row actions.'
+		);
+		$this->assertStringNotContainsString(
+			'edit.php?post_type=product&page=product_attributes&delete=' . $attribute_id,
+			$decoded_output,
+			'Filtered default actions should be removable.'
+		);
+	}
+
+	/**
+	 * Creates a global product attribute for the admin table.
+	 *
+	 * @param string $slug Attribute slug.
+	 * @return int Created attribute ID.
+	 */
+	private function create_test_attribute( string $slug = '' ): int {
+		++self::$attribute_counter;
+
+		$slug         = '' === $slug ? 'test_attr_' . self::$attribute_counter : $slug;
+		$attribute_id = wc_create_attribute(
+			array(
+				'name' => 'Test attribute ' . self::$attribute_counter,
+				'slug' => $slug,
+			)
+		);
+
+		$this->assertIsInt( $attribute_id, 'Test attribute should be created.' );
+		$this->attribute_ids[] = $attribute_id;
+
+		return $attribute_id;
+	}
+
+	/**
+	 * Renders the add attribute admin page.
+	 *
+	 * @return string Rendered HTML.
+	 */
+	private function render_add_attribute_page(): string {
+		ob_start();
+		WC_Admin_Attributes::add_attribute();
+		return (string) ob_get_clean();
+	}
+}