namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Caches\OrderCountCache;
use Automattic\WooCommerce\Utilities\OrderUtil;
* Admin list table for orders as managed by the OrdersTableDataStore.
class ListTable extends WP_List_Table {
private $request = array();
* Contains the arguments to be used in the order query.
private $order_query_args = array();
* Tracks if a filter (ie, date or customer filter) has been applied.
private $has_filter = false;
* Page controller instance for this request.
private $page_controller;
* Tracks whether we're currently inside the trash.
private $is_trash = false;
* Caches order counts by status.
private $status_count_cache = null;
* Sets up the admin list table for orders (specifically, for orders managed by the OrdersTableDataStore).
* @see WC_Admin_List_Table_Orders for the corresponding class used in relation to the traditional WP Post store.
public function __construct() {
* Init method, invoked by DI container.
* @internal This method is not intended to be used directly (except for testing).
* @param PageController $page_controller Page controller instance for this request.
final public function init( PageController $page_controller ) {
$this->page_controller = $page_controller;
* Performs setup work required before rendering the table.
* @param array $args Args to initialize this list table.
public function setup( $args = array() ): void {
$this->order_type = $args['order_type'] ?? 'shop_order';
add_action( 'admin_notices', array( $this, 'bulk_action_notices' ) );
add_filter( "manage_{$this->screen->id}_columns", array( $this, 'get_columns' ), 0 );
add_filter( 'set_screen_option_edit_' . $this->order_type . '_per_page', array( $this, 'set_items_per_page' ), 10, 3 );
add_filter( 'default_hidden_columns', array( $this, 'default_hidden_columns' ), 10, 2 );
add_action( 'admin_footer', array( $this, 'enqueue_scripts' ) );
add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'created_via_filter' ) );
add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'customers_filter' ) );
add_action( 'manage_' . wc_get_page_screen_id( $this->order_type ) . '_custom_column', array( $this, 'render_column' ), 10, 2 );
* Generates content for a single row of the table.
* @param \WC_Order $order The current order.
public function single_row( $order ) {
* Filters the list of CSS class names for a given order row in the orders list table.
* @param string[] $classes An array of CSS class names.
* @param \WC_Order $order The order object.
$css_classes = apply_filters(
'woocommerce_' . $this->order_type . '_list_table_order_css_classes',
'order-' . $order->get_id(),
'type-' . $order->get_type(),
'status-' . $order->get_status(),
$css_classes = array_unique( array_map( 'trim', $css_classes ) );
$edit_lock = wc_get_container()->get( EditLock::class );
if ( $edit_lock->is_locked_by_another_user( $order ) ) {
$css_classes[] = 'wp-locked';
echo '<tr id="order-' . esc_attr( $order->get_id() ) . '" class="' . esc_attr( implode( ' ', $css_classes ) ) . '">';
$this->single_row_columns( $order );
* Render individual column.
* @param string $column_id Column ID to render.
* @param WC_Order $order Order object.
public function render_column( $column_id, $order ) {
if ( is_callable( array( $this, 'render_' . $column_id . '_column' ) ) ) {
call_user_func( array( $this, 'render_' . $column_id . '_column' ), $order );
* Handles output for the default column.
* @param \WC_Order $order Current WooCommerce order object.
* @param string $column_name Identifier for the custom column.
public function column_default( $order, $column_name ) {
* Fires for each custom column for a specific order type. This hook takes precedence over the generic
* action `manage_{$this->screen->id}_custom_column`.
* @param string $column_name Identifier for the custom column.
* @param \WC_Order $order Current WooCommerce order object.
do_action( 'woocommerce_' . $this->order_type . '_list_table_custom_column', $column_name, $order );
* Fires for each custom column in the Custom Order Table in the administrative screen.
* @param string $column_name Identifier for the custom column.
* @param \WC_Order $order Current WooCommerce order object.
do_action( "manage_{$this->screen->id}_custom_column", $column_name, $order );
* Sets up an items-per-page control.
private function items_per_page(): void {
'option' => 'edit_' . $this->order_type . '_per_page',
* Saves the items-per-page setting.
* @param mixed $default The default value.
* @param string $option The option being configured.
* @param int $value The submitted option value.
public function set_items_per_page( $default, string $option, int $value ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.defaultFound -- backwards compat.
return 'edit_' . $this->order_type . '_per_page' === $option ? absint( $value ) : $default;
public function display() {
$post_type = get_post_type_object( $this->order_type );
$title = esc_html( $post_type->labels->name );
$add_new = esc_html( $post_type->labels->add_new );
$new_page_link = $this->page_controller->get_new_page_url( $this->order_type );
if ( ! empty( $this->order_query_args['s'] ) ) {
$search_label = '<span class="subtitle">';
$search_label .= sprintf(
/* translators: %s: Search query. */
__( 'Search results for: %s', 'woocommerce' ),
'<strong>' . esc_html( $this->order_query_args['s'] ) . '</strong>'
$search_label .= '</span>';
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
<h1 class='wp-heading-inline'>{$title}</h1>
<a href='" . esc_url( $new_page_link ) . "' class='page-title-action'>{$add_new}</a>
<hr class='wp-header-end'>"
if ( $this->should_render_blank_state() ) {
$this->render_blank_state();
echo '<form id="wc-orders-filter" method="get" action="' . esc_url( get_admin_url( null, 'admin.php' ) ) . '">';
$this->print_hidden_form_fields();
$this->search_box( esc_html__( 'Search orders', 'woocommerce' ), 'orders-search-input' );
* Renders advice in the event that no orders exist yet.
public function render_blank_state(): void {
<div class="woocommerce-BlankState">
<h2 class="woocommerce-BlankState-message">
<?php esc_html_e( 'When you receive a new order, it will appear here.', 'woocommerce' ); ?>
<div class="woocommerce-BlankState-buttons">
<a class="woocommerce-BlankState-cta button-primary button" target="_blank" href="https://woocommerce.com/document/managing-orders/?utm_source=blankslate&utm_medium=product&utm_content=ordersdoc&utm_campaign=woocommerceplugin"><?php esc_html_e( 'Learn more about orders', 'woocommerce' ); ?></a>
* Renders after the 'blank state' message for the order list table has rendered.
do_action( 'wc_marketplace_suggestions_orders_empty_state' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
* Retrieves the list of bulk actions available for this table.
protected function get_bulk_actions() {
$selected_status = $this->order_query_args['status'] ?? false;
if ( array( 'trash' ) === $selected_status ) {
'untrash' => __( 'Restore', 'woocommerce' ),
'delete' => __( 'Delete permanently', 'woocommerce' ),
'mark_processing' => __( 'Change status to processing', 'woocommerce' ),
'mark_on-hold' => __( 'Change status to on-hold', 'woocommerce' ),
'mark_completed' => __( 'Change status to completed', 'woocommerce' ),
'mark_cancelled' => __( 'Change status to cancelled', 'woocommerce' ),
'trash' => __( 'Move to Trash', 'woocommerce' ),
if ( wc_string_to_bool( get_option( 'woocommerce_allow_bulk_remove_personal_data', 'no' ) ) ) {
$actions['remove_personal_data'] = __( 'Remove personal data', 'woocommerce' );
* Gets a list of CSS classes for the WP_List_Table table tag.
* @return string[] Array of CSS classes for the table tag.
protected function get_table_classes() {
* Filters the list of CSS class names for the orders list table.
* @param string[] $classes An array of CSS class names.
* @param string $order_type The order type.
$css_classes = apply_filters(
'woocommerce_' . $this->order_type . '_list_table_css_classes',
parent::get_table_classes(),
'wc-orders-list-table-' . $this->order_type,
return array_unique( array_map( 'trim', $css_classes ) );
* Prepares the list of items for displaying.
public function prepare_items() {
$limit = $this->get_items_per_page( 'edit_' . $this->order_type . '_per_page' );
$this->order_query_args = array(
'page' => $this->get_pagenum(),
'type' => $this->order_type,
foreach ( array( 'status', 's', 'm', '_customer_user', 'search-filter' ) as $query_var ) {
$this->request[ $query_var ] = sanitize_text_field( wp_unslash( $_REQUEST[ $query_var ] ?? '' ) );
* Allows 3rd parties to filter the initial request vars before defaults and other logic is applied.
* @param array $request Request to be passed to `wc_get_orders()`.
$this->request = apply_filters( 'woocommerce_' . $this->order_type . '_list_table_request', $this->request );
$this->set_status_args();
$this->set_customer_args();
$this->set_search_args();
$this->set_created_via_args();
* Provides an opportunity to modify the query arguments used in the (Custom Order Table-powered) order list
* @param array $query_args Arguments to be passed to `wc_get_orders()`.
$order_query_args = (array) apply_filters( 'woocommerce_order_list_table_prepare_items_query_args', $this->order_query_args );
* Same as `woocommerce_order_list_table_prepare_items_query_args` but for a specific order type.
* @param array $query_args Arguments to be passed to `wc_get_orders()`.
$order_query_args = apply_filters( 'woocommerce_' . $this->order_type . '_list_table_prepare_items_query_args', $order_query_args );
// We must ensure the 'paginate' argument is set.
$order_query_args['paginate'] = true;
// Attempt to use cache if no additional query arguments are used.
if ( empty( array_diff( array_keys( $this->order_query_args ), array( 'limit', 'page', 'paginate', 'type', 'status', 'orderby', 'order' ) ) ) ) {
$this->order_query_args['no_found_rows'] = true;
$order_query_args['no_found_rows'] = true;
$orders = wc_get_orders( $order_query_args );
$this->items = $orders->orders;
$max_num_pages = $this->get_max_num_pages( $orders );
// Check in case the user has attempted to page beyond the available range of orders.
if ( 0 === $max_num_pages && $this->order_query_args['page'] > 1 ) {
$count_query_args = $order_query_args;
$count_query_args['page'] = 1;
$count_query_args['limit'] = 1;
$order_count = wc_get_orders( $count_query_args );
$max_num_pages = (int) ceil( $order_count->total / $order_query_args['limit'] );
$this->set_pagination_args(
'total_items' => $orders->total ?? 0,
'total_pages' => $max_num_pages,
// Are we inside the trash?
$this->is_trash = 'trash' === $this->request['status'];
* Get the max number of pages from orders or from cache.
* @param WC_Order[]|stdClass Number of pages and an array of order objects.
private function get_max_num_pages( &$orders ) {
if ( ! isset( $this->order_query_args['no_found_rows'] ) || ! $this->order_query_args['no_found_rows'] ) {
return $orders->max_num_pages;
$count = $this->count_orders_by_status( $this->order_query_args['status'] );
$limit = $this->get_items_per_page( 'edit_' . $this->order_type . '_per_page' );
return ceil( $count / $limit );
* Updates the WC Order Query arguments as needed to support orderable columns.
private function set_order_args() {
$sortable = $this->get_sortable_columns();
$field = sanitize_text_field( wp_unslash( $_GET['orderby'] ?? '' ) );
$direction = strtoupper( sanitize_text_field( wp_unslash( $_GET['order'] ?? '' ) ) );
if ( ! in_array( $field, $sortable, true ) ) {
$this->order_query_args['orderby'] = 'date';
$this->order_query_args['order'] = 'DESC';
$this->order_query_args['orderby'] = $field;
$this->order_query_args['order'] = in_array( $direction, array( 'ASC', 'DESC' ), true ) ? $direction : 'ASC';
* Implements date (month-based) filtering.
private function set_date_args() {
$year_month = sanitize_text_field( wp_unslash( $_GET['m'] ?? '' ) );
if ( empty( $year_month ) || ! preg_match( '/^[0-9]{6}$/', $year_month ) ) {
$year = (int) substr( $year_month, 0, 4 );
$month = (int) substr( $year_month, 4, 2 );