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&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&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 ) ); ?>&post_type=product"><?php echo esc_html( $tax->attribute_label ); ?></a></strong>
+ <strong><a href="edit-tags.php?taxonomy=<?php echo esc_attr( $taxonomy ); ?>&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&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&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">–</span><br />';
}//end if
?>
- <br /><a href="edit-tags.php?taxonomy=<?php echo esc_attr( wc_attribute_taxonomy_name( $tax->attribute_name ) ); ?>&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 ); ?>&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&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();
+ }
+}