Commit 525a2a3602d for woocommerce
commit 525a2a3602d29c63ce5b769326420b1c5eb6a5c3
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Thu Aug 13 19:59:10 2026 +0300
Add composable filters to My Account order columns (#67510)
* feat: filter account order columns
[Context]
The My Account orders template exposes column-specific actions that let extensions replace an entire cell.
[Problem]
Because the template skips default output whenever one of those actions is registered, extensions that only need to modify a value have to re-render the whole cell. Multiple extensions targeting the same column can duplicate or conflict with the default data.
[Solution]
Keep the existing action hooks as full-cell replacements, then filter the default rendered column HTML through woocommerce_account_orders_column_{} when no replacement action is present. Add template rendering coverage for composable filters and the preserved legacy action behavior.
Refs #35635
* fix: avoid account order column hook collisions
The proposed default-content filter reused an action-style hook prefix already present in third-party integrations. Since WordPress actions and filters share one registry, those callbacks could receive HTML where they expect a WC_Order and fail during My Account rendering.\n\nUse an explicit column-content hook prefix that does not overlap the existing ecosystem convention. Preserve legacy full-cell replacement, and expand template coverage across every built-in column, custom columns, public filter arguments, composability, and filter suppression.\n\nRefs #35635
* fix: include account order filters in public changelog
[Context]
The My Account order-column filter change includes a patch/tweak changefile for release communication.
[Problem]
Its intended release note was stored only in the Comment header. Changelogger treats that field as developer metadata and ignores it when compiling the public changelog, leaving the actual entry body empty.
[Solution]
Move the release note below the changefile header separator so Changelogger consumes it as public entry content while preserving the existing significance, type, and wording.
Refs #35635
* fix: validate My Account order column filter output
[Context]
The My Account orders template exposes filtered default HTML so extensions can compose changes to individual columns.
[Problem]
WordPress filters may return any value regardless of the documented contract. Echoing a malformed array or non-stringable object produces a warning or fatal error and can break the customer Orders page.
[Solution]
Render filtered content only when it is a string and otherwise fall back to the already escaped default HTML. Cover representative array and WP_Error returns through the active template so invalid callbacks cannot regress page rendering.
Refs #35635
* fix: compose account order column filters with legacy action output
The new woocommerce_account_orders_column_content_{$column_id} filter
only ran when no callback was attached to the legacy
woocommerce_my_account_my_orders_column_{$column_id} action, so an
extension migrating to the filter lost its output on any site where
another plugin still used the action on the same column — a silent
failure that punished exactly the extensions adopting the new hook.
Buffer the whole cell and run the filter unconditionally over whatever
the cell produced, matching the ReviewsListTable column filter shape
(since 6.7.0). Legacy actions keep their replace-the-cell semantics;
the filter becomes the single reliable seam and composes over action
output. The buffered content is trimmed so callbacks receive an empty
string, not template whitespace, for custom columns. Filter returns
are coerced when stringable (int, float, __toString objects) and
rejected with wc_doing_it_wrong() otherwise, instead of silently
falling back; blind (string) coercion was not an option since it
fatals on non-stringable objects such as WP_Error.
* fix: mark the account order filters changelog entry as an addition
The entry was classified as a tweak and did not mention the template
version bump. A new public hook is an addition, and the
myaccount/orders.php @version bump flags theme overrides as outdated
in WooCommerce → Status, so the entry now uses Type: add and names
both the filter and the bump so theme authors catch it in the release
notes.
* docs: document accessibility affordances for order column filters
Three of the five default My Account order columns carry their only
accessible affordances inside the markup handed to filter callbacks:
the order number link's aria-label (its visible text is just the order
number), the order action aria-labels, and the order date <time
datetime> attribute.
A callback that replaces the cell content instead of appending to it
drops those affordances silently, and nothing in the hook contract
warns about it.
State the expectation in the filter docblock so callback authors carry
the affordances over when they replace content.
Refs #35635
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: warn against dual hook registration for order column filters
The filter docblock notes that theme overrides predating template
version 11.1.0 never run the filter, but stops short of saying what to
do about it. The obvious reading, registering the legacy action
alongside the filter so both site generations stay covered, is wrong.
When the legacy action has callbacks its output replaces the default
cell content and becomes this filter's input, so an extension that
registers both hooks with the same markup renders that markup twice on
up-to-date templates. Coverage for outdated overrides has to come from
the action instead of the filter, not from both at once.
State the interaction next to the override note so it does not lead
callback authors into double rendering.
Refs #35635
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: cover the empty-string return contract for order column filters
The template treats an empty string as valid filtered content and
renders an empty cell, while non-string returns fall back to the
unfiltered content. Only the fallback side was covered: the existing
custom-column test starts from empty content, so it cannot tell an
emptied cell apart from a fallback to an equally empty default.
Assert that a filter returning an empty string on a column that does
have default content clears it. Mutating the template to reject empty
strings kills this test, so the empty-versus-invalid boundary is now
pinned against refactors that would collapse it.
Refs #35635
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-35635-account-orders-column-filter b/plugins/woocommerce/changelog/fix-35635-account-orders-column-filter
new file mode 100644
index 00000000000..aa446dc0052
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-35635-account-orders-column-filter
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add composable woocommerce_account_orders_column_content_{$column_id} filters for the My Account orders table columns and bump the myaccount/orders.php template version to 11.1.0.
diff --git a/plugins/woocommerce/templates/myaccount/orders.php b/plugins/woocommerce/templates/myaccount/orders.php
index 5a54c6561d7..ba867e0938b 100644
--- a/plugins/woocommerce/templates/myaccount/orders.php
+++ b/plugins/woocommerce/templates/myaccount/orders.php
@@ -14,7 +14,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 9.5.0
+ * @version 11.1.0
*/
defined( 'ABSPATH' ) || exit;
@@ -35,7 +35,10 @@ do_action( 'woocommerce_before_account_orders', $has_orders ); ?>
<tbody>
<?php
foreach ( $customer_orders->orders as $customer_order ) {
- $order = wc_get_order( $customer_order ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $order = wc_get_order( $customer_order ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ if ( ! $order instanceof WC_Order ) {
+ continue;
+ }
$item_count = $order->get_item_count() - $order->get_item_count_refunded();
?>
<tr class="woocommerce-orders-table__row woocommerce-orders-table__row--status-<?php echo esc_attr( $order->get_status() ); ?> order">
@@ -48,6 +51,8 @@ do_action( 'woocommerce_before_account_orders', $has_orders ); ?>
<td class="woocommerce-orders-table__cell woocommerce-orders-table__cell-<?php echo esc_attr( $column_id ); ?>" data-title="<?php echo esc_attr( $column_name ); ?>">
<?php endif; ?>
+ <?php ob_start(); ?>
+
<?php if ( has_action( 'woocommerce_my_account_my_orders_column_' . $column_id ) ) : ?>
<?php do_action( 'woocommerce_my_account_my_orders_column_' . $column_id, $order ); ?>
@@ -89,6 +94,54 @@ do_action( 'woocommerce_before_account_orders', $has_orders ); ?>
?>
<?php endif; ?>
+ <?php
+ $column_content = trim( (string) ob_get_clean() );
+
+ /**
+ * Filters the content of a My Account orders table column.
+ *
+ * The dynamic portion of the hook name, `$column_id`, refers to the
+ * order table column ID. The filter receives the full cell content:
+ * the default column HTML or, when callbacks are registered on the
+ * `woocommerce_my_account_my_orders_column_{$column_id}` action, the
+ * output of those callbacks. Default content is escaped before this
+ * filter runs, and callbacks should return safe, escaped HTML.
+ *
+ * Callbacks that replace the content rather than append to it should
+ * carry over the default markup's accessibility affordances: the order
+ * number link's `aria-label`, the order action `aria-label`s, and the
+ * order date `<time datetime>` attribute.
+ *
+ * This filter runs from the `myaccount/orders.php` template, so it is
+ * not available on sites where a theme overrides the template with a
+ * copy predating version 11.1.0. Registering the legacy action as well
+ * is not a workaround for that: the action still suppresses the default
+ * content and this filter then runs over the action's output, so
+ * callbacks on both hooks emitting the same markup render it twice.
+ *
+ * @param string $column_content Current column cell HTML.
+ * @param WC_Order $order Current order object.
+ * @param string $column_id Current column ID.
+ *
+ * @since 11.1.0
+ */
+ $filtered_column_content = apply_filters( 'woocommerce_account_orders_column_content_' . $column_id, $column_content, $order, $column_id );
+
+ if ( is_string( $filtered_column_content ) ) {
+ $column_content = $filtered_column_content;
+ } elseif ( is_int( $filtered_column_content ) || is_float( $filtered_column_content ) || ( is_object( $filtered_column_content ) && method_exists( $filtered_column_content, '__toString' ) ) ) {
+ $column_content = (string) $filtered_column_content;
+ } else {
+ wc_doing_it_wrong(
+ 'woocommerce_account_orders_column_content_' . $column_id,
+ __( 'Filter callbacks must return a string (or stringable value) of safe, escaped HTML. Return an empty string to render an empty cell. The unfiltered column content was used instead.', 'woocommerce' ),
+ '11.1.0'
+ );
+ }
+
+ echo $column_content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Contains escaped default content or action hook output; filter callbacks must return safe HTML.
+ ?>
+
<?php if ( $is_order_number ) : ?>
</th>
<?php else : ?>
diff --git a/plugins/woocommerce/tests/php/includes/templates/class-wc-my-account-orders-template-test.php b/plugins/woocommerce/tests/php/includes/templates/class-wc-my-account-orders-template-test.php
new file mode 100644
index 00000000000..711b596a9ad
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/templates/class-wc-my-account-orders-template-test.php
@@ -0,0 +1,374 @@
+<?php
+/**
+ * Tests for the My Account orders template.
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * My Account orders template test.
+ */
+class WC_My_Account_Orders_Template_Test extends WC_Unit_Test_Case {
+
+ /**
+ * Hooks registered by the current test.
+ *
+ * @var array<int, array{type: 'filter'|'action', hook_name: string, callback: callable, priority: int}>
+ */
+ private array $registered_hooks = array();
+
+ /**
+ * Tear down the test.
+ */
+ public function tearDown(): void {
+ foreach ( $this->registered_hooks as $hook ) {
+ if ( 'filter' === $hook['type'] ) {
+ remove_filter( $hook['hook_name'], $hook['callback'], $hook['priority'] );
+ } else {
+ remove_action( $hook['hook_name'], $hook['callback'], $hook['priority'] );
+ }
+ }
+
+ $this->registered_hooks = array();
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Default order status output can be changed by composable filters.
+ */
+ public function test_order_status_column_filters_compose_with_default_output(): void {
+ $order = $this->create_order_with_status( 'processing' );
+
+ $this->add_test_filter(
+ 'woocommerce_account_orders_column_content_order-status',
+ static function ( string $column_content ): string {
+ return $column_content . '<span class="first-filter">First filter</span>';
+ },
+ 10,
+ 1
+ );
+ $this->add_test_filter(
+ 'woocommerce_account_orders_column_content_order-status',
+ static function ( string $column_content ): string {
+ return $column_content . '<span class="second-filter">Second filter</span>';
+ },
+ 20,
+ 1
+ );
+
+ $html = $this->render_orders_template( $order );
+
+ $this->assertStringContainsString( 'Processing', $html, 'Default order status output should remain available to filters.' );
+ $this->assertStringContainsString( 'First filter', $html, 'First filter output should render.' );
+ $this->assertStringContainsString( 'Second filter', $html, 'Second filter output should render.' );
+ $this->assertSame( 1, substr_count( $html, 'Processing' ), 'Default order status should not be duplicated when filters compose.' );
+ }
+
+ /**
+ * @testdox Non-stringable filtered column content falls back to default output with a doing-it-wrong notice.
+ */
+ public function test_non_string_filtered_column_content_falls_back_to_default_output(): void {
+ $this->setExpectedIncorrectUsage( 'woocommerce_account_orders_column_content_order-status' );
+
+ $order = $this->create_order_with_status( 'processing' );
+ $filtered_content = null;
+
+ $this->add_test_filter(
+ 'woocommerce_account_orders_column_content_order-status',
+ static function () use ( &$filtered_content ) {
+ return $filtered_content;
+ }
+ );
+
+ foreach ( array( array( 'invalid' ), new WP_Error( 'invalid' ), null, false ) as $filtered_content ) {
+ $html = $this->render_orders_template( $order );
+
+ $this->assertStringContainsString( 'Processing', $html, 'Invalid filtered content should fall back to default output.' );
+ $this->assertSame( 1, substr_count( $html, 'Processing' ), 'Default output should render once after fallback.' );
+ }
+ }
+
+ /**
+ * @testdox An empty filtered column content clears the default output instead of falling back to it.
+ */
+ public function test_empty_filtered_column_content_clears_default_output(): void {
+ $order = $this->create_order_with_status( 'processing' );
+
+ $this->add_test_filter(
+ 'woocommerce_account_orders_column_content_order-status',
+ static function (): string {
+ return '';
+ }
+ );
+
+ $html = $this->render_orders_template( $order );
+
+ $this->assertStringNotContainsString( 'Processing', $html, 'An empty string is valid content and should not fall back to the default output.' );
+ $this->assertStringContainsString( 'woocommerce-orders-table__cell-order-status', $html, 'The emptied column should still render its cell.' );
+ }
+
+ /**
+ * @testdox Numeric and stringable filtered column content is coerced to a string.
+ */
+ public function test_numeric_and_stringable_filtered_column_content_is_coerced(): void {
+ $order = $this->create_order_with_status( 'processing' );
+ $stringable = new class() {
+ /**
+ * Render as string.
+ *
+ * @return string
+ */
+ public function __toString(): string {
+ return '<span class="stringable-status">Stringable status</span>';
+ }
+ };
+
+ $filtered_content = null;
+ $this->add_test_filter(
+ 'woocommerce_account_orders_column_content_order-status',
+ static function () use ( &$filtered_content ) {
+ return $filtered_content;
+ }
+ );
+
+ foreach ( array( 42, 4.5, $stringable ) as $filtered_content ) {
+ $html = $this->render_orders_template( $order );
+
+ $expected = is_object( $filtered_content ) ? 'Stringable status' : (string) $filtered_content;
+ $this->assertStringContainsString( $expected, $html, 'Stringable filtered content should be coerced and rendered.' );
+ $this->assertStringNotContainsString( 'Processing', $html, 'Coerced filtered content should replace the default output.' );
+ }
+ }
+
+ /**
+ * @testdox Content filters compose with legacy column action output.
+ */
+ public function test_content_filters_compose_with_legacy_action_output(): void {
+ $order = $this->create_order_with_status( 'processing' );
+ $filter_calls = 0;
+ $received_content = null;
+
+ $this->add_test_filter(
+ 'woocommerce_account_orders_column_content_order-status',
+ static function ( string $column_content ) use ( &$filter_calls, &$received_content ): string {
+ ++$filter_calls;
+ $received_content = $column_content;
+ return $column_content . '<span class="filtered-status">Filtered status</span>';
+ }
+ );
+ $this->add_test_action(
+ 'woocommerce_my_account_my_orders_column_order-status',
+ static function (): void {
+ echo '<span class="legacy-status">Legacy status</span>';
+ }
+ );
+
+ $html = $this->render_orders_template( $order );
+
+ $this->assertStringContainsString( 'Legacy status', $html, 'Existing action callbacks should still render.' );
+ $this->assertStringNotContainsString( 'Processing', $html, 'Existing action callbacks should still suppress default output.' );
+ $this->assertSame( 1, $filter_calls, 'Content filters should run when a legacy action replaces the default content.' );
+ $this->assertStringContainsString( 'legacy-status', (string) $received_content, 'Content filters should receive the legacy action output.' );
+ $this->assertStringContainsString( 'Filtered status', $html, 'Content filters should compose over legacy action output.' );
+ }
+
+ /**
+ * @testdox Built-in order columns expose their default content and context to filters.
+ * @dataProvider default_column_provider
+ *
+ * @param string $column_id Column ID.
+ * @param string $default_content Semantic content expected from the default renderer.
+ */
+ public function test_default_order_columns_are_filterable( string $column_id, string $default_content ): void {
+ $order = $this->create_order_with_status( 'processing' );
+ $filtered_content = null;
+ $filtered_order = null;
+ $filtered_column_id = null;
+
+ $this->add_test_filter(
+ 'woocommerce_account_orders_column_content_' . $column_id,
+ static function ( string $content, WC_Order $current_order, string $current_column_id ) use ( &$filtered_content, &$filtered_order, &$filtered_column_id ): string {
+ $filtered_content = $content;
+ $filtered_order = $current_order;
+ $filtered_column_id = $current_column_id;
+ return $content . '<span class="column-filter-marker">Filtered column</span>';
+ },
+ 10,
+ 3
+ );
+
+ $html = $this->render_orders_template( $order );
+
+ $this->assertIsString( $filtered_content, 'The column content filter should run.' );
+ $this->assertStringContainsString( $default_content, $filtered_content, 'The filter should receive the existing default column HTML.' );
+ $this->assertSame( trim( $filtered_content ), $filtered_content, 'The filter should receive content without surrounding template whitespace.' );
+ $this->assertInstanceOf( WC_Order::class, $filtered_order, 'The filter should receive an order object.' );
+ $this->assertSame( $order->get_id(), $filtered_order instanceof WC_Order ? $filtered_order->get_id() : null, 'The filter should receive the current order.' );
+ $this->assertSame( $column_id, $filtered_column_id, 'The filter should receive the current column ID.' );
+ $this->assertStringContainsString( 'Filtered column', $html, 'The filtered column HTML should render.' );
+ }
+
+ /**
+ * @testdox Existing account order column actions do not collide with content filters.
+ */
+ public function test_existing_account_order_column_actions_do_not_collide_with_content_filters(): void {
+ $order = $this->create_order_with_status( 'processing' );
+ $legacy_action_calls = 0;
+
+ $this->add_test_filter(
+ 'woocommerce_account_orders_columns',
+ static function ( array $columns ): array {
+ $columns['order-type'] = 'Order type';
+ return $columns;
+ }
+ );
+ $this->add_test_action(
+ 'woocommerce_account_orders_column_order-type',
+ static function () use ( &$legacy_action_calls ): void {
+ ++$legacy_action_calls;
+ }
+ );
+ $this->add_test_filter(
+ 'woocommerce_account_orders_column_content_order-type',
+ static function (): string {
+ return '<span class="filtered-order-type">Filtered order type</span>';
+ }
+ );
+
+ $html = $this->render_orders_template( $order );
+
+ $this->assertSame( 0, $legacy_action_calls, 'A content filter should not invoke callbacks registered on the existing action-style hook name.' );
+ $this->assertStringContainsString( 'Filtered order type', $html, 'A custom column should render content from its filter.' );
+ }
+
+ /**
+ * @testdox A custom column without default content exposes an empty string to filters.
+ */
+ public function test_custom_column_content_filter_receives_empty_string(): void {
+ $order = $this->create_order_with_status( 'processing' );
+ $received_content = null;
+
+ $this->add_test_filter(
+ 'woocommerce_account_orders_columns',
+ static function ( array $columns ): array {
+ $columns['order-custom'] = 'Custom';
+ return $columns;
+ }
+ );
+ $this->add_test_filter(
+ 'woocommerce_account_orders_column_content_order-custom',
+ static function ( string $column_content ) use ( &$received_content ): string {
+ $received_content = $column_content;
+ return $column_content;
+ }
+ );
+
+ $this->render_orders_template( $order );
+
+ $this->assertSame( '', $received_content, 'A custom column with no default renderer should expose an empty string, not template whitespace.' );
+ }
+
+ /**
+ * @testdox Rows whose order cannot be resolved are skipped while valid rows render.
+ */
+ public function test_rows_with_unresolvable_orders_are_skipped(): void {
+ $order = $this->create_order_with_status( 'processing' );
+
+ $html = $this->render_orders_template( $order, array( PHP_INT_MAX, $order->get_id() ) );
+
+ $this->assertStringContainsString( 'View order number ' . $order->get_order_number(), $html, 'The valid order row should render.' );
+ $this->assertSame( 1, substr_count( $html, '<tr class="woocommerce-orders-table__row' ), 'The unresolvable order row should be skipped.' );
+ }
+
+ /**
+ * Built-in columns and representative default content.
+ *
+ * @return array<string, array{string, string}>
+ */
+ public function default_column_provider(): array {
+ return array(
+ 'order number' => array( 'order-number', 'View order number' ),
+ 'order date' => array( 'order-date', '<time datetime=' ),
+ 'order status' => array( 'order-status', 'Processing' ),
+ 'order total' => array( 'order-total', 'woocommerce-Price-amount' ),
+ 'order actions' => array( 'order-actions', 'woocommerce-button' ),
+ );
+ }
+
+ /**
+ * Create an order with a specific status.
+ *
+ * @param string $status Order status without wc- prefix.
+ * @return WC_Order
+ */
+ private function create_order_with_status( string $status ): WC_Order {
+ $order = wc_create_order( array( 'status' => $status ) );
+ if ( is_wp_error( $order ) ) {
+ throw new RuntimeException( 'Could not create an order for the template test.' );
+ }
+
+ return $order;
+ }
+
+ /**
+ * Register a filter that will be removed during tear down.
+ *
+ * @param string $hook_name Filter name.
+ * @param callable $callback Filter callback.
+ * @param int $priority Filter priority.
+ * @param int $accepted_args Number of accepted arguments.
+ */
+ private function add_test_filter( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 ): void {
+ add_filter( $hook_name, $callback, $priority, $accepted_args );
+ $this->registered_hooks[] = array(
+ 'type' => 'filter',
+ 'hook_name' => $hook_name,
+ 'callback' => $callback,
+ 'priority' => $priority,
+ );
+ }
+
+ /**
+ * Register an action that will be removed during tear down.
+ *
+ * @param string $hook_name Action name.
+ * @param callable $callback Action callback.
+ * @param int $priority Action priority.
+ * @param int $accepted_args Number of accepted arguments.
+ */
+ private function add_test_action( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 ): void {
+ add_action( $hook_name, $callback, $priority, $accepted_args );
+ $this->registered_hooks[] = array(
+ 'type' => 'action',
+ 'hook_name' => $hook_name,
+ 'callback' => $callback,
+ 'priority' => $priority,
+ );
+ }
+
+ /**
+ * Render the active My Account orders template.
+ *
+ * @param WC_Order $order Order object.
+ * @param array<int>|null $order_ids Order IDs to render. Defaults to the given order's ID.
+ * @return string
+ */
+ private function render_orders_template( WC_Order $order, ?array $order_ids = null ): string {
+ $order_ids = $order_ids ?? array( $order->get_id() );
+
+ return wc_get_template_html(
+ 'myaccount/orders.php',
+ array(
+ 'current_page' => 1,
+ 'customer_orders' => (object) array(
+ 'orders' => $order_ids,
+ 'total' => count( $order_ids ),
+ 'max_num_pages' => 1,
+ ),
+ 'has_orders' => true,
+ 'wp_button_class' => '',
+ )
+ );
+ }
+}