PluginProbe
WooCommerce / 11.1.0-rc.2
WooCommerce v11.1.0-rc.2
11.1.0 11.1.0-rc.2 11.1.0-rc.1 11.1.0-beta.2 11.1.0-beta.1 11.0.1 11.0.0 11.0.0-rc.3 11.0.0-rc.2 11.0.0-rc.1 11.0.0-beta.2 11.0.0-beta.1 10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 All 648 releases
woocommerce / src / Internal / Admin / Orders / ListTable.php
ListTable.php
1,853 lines 58.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Automattic\WooCommerce\Internal\Admin\Orders;
4
5 use Automattic\WooCommerce\Enums\OrderStatus;
6 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
7 use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
8 use Automattic\WooCommerce\Caches\OrderCountCache;
9 use Automattic\WooCommerce\Utilities\OrderUtil;
10 use WC_Order;
11 use WP_List_Table;
12 use WP_Screen;
13
14 /**
15 * Admin list table for orders as managed by the OrdersTableDataStore.
16 */
17 class ListTable extends WP_List_Table {
18
19 /**
20 * Order type.
21 *
22 * @var string
23 */
24 private $order_type;
25
26 /**
27 * Underlying WordPress post type. Used for checking permissions.
28 *
29 * @var WP_Post_Type|null
30 */
31 private $wp_post_type;
32
33 /**
34 * Request vars.
35 *
36 * @var array
37 */
38 private $request = array();
39
40 /**
41 * Contains the arguments to be used in the order query.
42 *
43 * @var array
44 */
45 private $order_query_args = array();
46
47 /**
48 * Tracks if a filter (ie, date or customer filter) has been applied.
49 *
50 * @var bool
51 */
52 private $has_filter = false;
53
54 /**
55 * Page controller instance for this request.
56 *
57 * @var PageController
58 */
59 private $page_controller;
60
61 /**
62 * Tracks whether we're currently inside the trash.
63 *
64 * @var boolean
65 */
66 private $is_trash = false;
67
68 /**
69 * Caches order counts by status.
70 *
71 * @var array
72 */
73 private $status_count_cache = null;
74
75 /**
76 * Sets up the admin list table for orders (specifically, for orders managed by the OrdersTableDataStore).
77 *
78 * @see WC_Admin_List_Table_Orders for the corresponding class used in relation to the traditional WP Post store.
79 */
80 public function __construct() {
81 parent::__construct(
82 array(
83 'singular' => 'order',
84 'plural' => 'orders',
85 'ajax' => false,
86 )
87 );
88 }
89
90 /**
91 * Init method, invoked by DI container.
92 *
93 * @internal This method is not intended to be used directly (except for testing).
94 * @param PageController $page_controller Page controller instance for this request.
95 */
96 final public function init( PageController $page_controller ) {
97 $this->page_controller = $page_controller;
98 }
99
100 /**
101 * Performs setup work required before rendering the table.
102 *
103 * @param array $args Args to initialize this list table.
104 *
105 * @return void
106 */
107 public function setup( $args = array() ): void {
108 $this->order_type = $args['order_type'] ?? 'shop_order';
109 $this->wp_post_type = get_post_type_object( $this->order_type );
110
111 add_action( 'admin_notices', array( $this, 'bulk_action_notices' ) );
112 add_filter( "manage_{$this->screen->id}_columns", array( $this, 'get_columns' ), 0 );
113 add_filter( 'set_screen_option_edit_' . $this->order_type . '_per_page', array( $this, 'set_items_per_page' ), 10, 3 );
114 add_filter( 'default_hidden_columns', array( $this, 'default_hidden_columns' ), 10, 2 );
115 add_action( 'admin_footer', array( $this, 'enqueue_scripts' ) );
116 add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'created_via_filter' ) );
117 add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'customers_filter' ) );
118
119 $this->items_per_page();
120 set_screen_options();
121
122 add_action( 'manage_' . wc_get_page_screen_id( $this->order_type ) . '_custom_column', array( $this, 'render_column' ), 10, 2 );
123 }
124
125 /**
126 * Generates content for a single row of the table.
127 *
128 * @since 7.8.0
129 *
130 * @param \WC_Order $order The current order.
131 */
132 public function single_row( $order ) {
133 /**
134 * Filters the list of CSS class names for a given order row in the orders list table.
135 *
136 * @since 7.8.0
137 *
138 * @param string[] $classes An array of CSS class names.
139 * @param \WC_Order $order The order object.
140 */
141 $css_classes = apply_filters(
142 'woocommerce_' . $this->order_type . '_list_table_order_css_classes',
143 array(
144 'order-' . $order->get_id(),
145 'type-' . $order->get_type(),
146 'status-' . $order->get_status(),
147 ),
148 $order
149 );
150 $css_classes = array_unique( array_map( 'trim', $css_classes ) );
151
152 // Is locked?
153 $edit_lock = wc_get_container()->get( EditLock::class );
154 if ( $edit_lock->is_locked_by_another_user( $order ) ) {
155 $css_classes[] = 'wp-locked';
156 }
157
158 echo '<tr id="order-' . esc_attr( $order->get_id() ) . '" class="' . esc_attr( implode( ' ', $css_classes ) ) . '">';
159 $this->single_row_columns( $order );
160 echo '</tr>';
161 }
162
163 /**
164 * Render individual column.
165 *
166 * @param string $column_id Column ID to render.
167 * @param WC_Order $order Order object.
168 */
169 public function render_column( $column_id, $order ) {
170 if ( ! $order ) {
171 return;
172 }
173
174 if ( is_callable( array( $this, 'render_' . $column_id . '_column' ) ) ) {
175 call_user_func( array( $this, 'render_' . $column_id . '_column' ), $order );
176 }
177 }
178
179 /**
180 * Handles output for the default column.
181 *
182 * @param \WC_Order $order Current WooCommerce order object.
183 * @param string $column_name Identifier for the custom column.
184 */
185 public function column_default( $order, $column_name ) {
186 /**
187 * Fires for each custom column for a specific order type. This hook takes precedence over the generic
188 * action `manage_{$this->screen->id}_custom_column`.
189 *
190 * @param string $column_name Identifier for the custom column.
191 * @param \WC_Order $order Current WooCommerce order object.
192 *
193 * @since 7.3.0
194 */
195 do_action( 'woocommerce_' . $this->order_type . '_list_table_custom_column', $column_name, $order );
196
197 /**
198 * Fires for each custom column in the Custom Order Table in the administrative screen.
199 *
200 * @param string $column_name Identifier for the custom column.
201 * @param \WC_Order $order Current WooCommerce order object.
202 *
203 * @since 7.0.0
204 */
205 do_action( "manage_{$this->screen->id}_custom_column", $column_name, $order );
206 }
207
208 /**
209 * Sets up an items-per-page control.
210 */
211 private function items_per_page(): void {
212 add_screen_option(
213 'per_page',
214 array(
215 'default' => 20,
216 'option' => 'edit_' . $this->order_type . '_per_page',
217 )
218 );
219 }
220
221 /**
222 * Saves the items-per-page setting.
223 *
224 * @param mixed $default The default value.
225 * @param string $option The option being configured.
226 * @param int $value The submitted option value.
227 *
228 * @return mixed
229 */
230 public function set_items_per_page( $default, string $option, int $value ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.defaultFound -- backwards compat.
231 return 'edit_' . $this->order_type . '_per_page' === $option ? absint( $value ) : $default;
232 }
233
234 /**
235 * Render the table.
236 *
237 * @return void
238 */
239 public function display() {
240 $post_type = get_post_type_object( $this->order_type );
241
242 $title = esc_html( $post_type->labels->name );
243 $add_new = esc_html( $post_type->labels->add_new );
244 $new_page_link = $this->page_controller->get_new_page_url( $this->order_type );
245 $search_label = '';
246
247 if ( ! empty( $this->order_query_args['s'] ) ) {
248 $search_label = '<span class="subtitle">';
249 $search_label .= sprintf(
250 /* translators: %s: Search query. */
251 __( 'Search results for: %s', 'woocommerce' ),
252 '<strong>' . esc_html( $this->order_query_args['s'] ) . '</strong>'
253 );
254 $search_label .= '</span>';
255 }
256
257 // Add new.
258 $add_new_button = '';
259 if ( $post_type && current_user_can( $post_type->cap->publish_posts ) ) {
260 $add_new_button = "<a href='" . esc_url( $new_page_link ) . "' class='page-title-action'>{$add_new}</a>";
261 }
262
263 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
264 echo wp_kses_post(
265 "
266 <div class='wrap'>
267 <h1 class='wp-heading-inline'>{$title}</h1>
268 {$add_new_button}
269 {$search_label}
270 <hr class='wp-header-end'>"
271 );
272
273 if ( $this->should_render_blank_state() ) {
274 $this->render_blank_state();
275 return;
276 }
277
278 $this->views();
279
280 echo '<form id="wc-orders-filter" method="get" action="' . esc_url( get_admin_url( null, 'admin.php' ) ) . '">';
281 $this->print_hidden_form_fields();
282 $this->search_box( esc_html__( 'Search orders', 'woocommerce' ), 'orders-search-input' );
283
284 parent::display();
285 echo '</form> </div>';
286 }
287
288 /**
289 * Renders advice in the event that no orders exist yet.
290 *
291 * @return void
292 */
293 public function render_blank_state(): void {
294 ?>
295 <div class="woocommerce-BlankState woocommerce-BlankState--orders">
296
297 <h2 class="woocommerce-BlankState-message">
298 <?php esc_html_e( 'When you receive a new order, it will appear here.', 'woocommerce' ); ?>
299 </h2>
300
301 <div class="woocommerce-BlankState-buttons">
302 <a class="woocommerce-BlankState-cta button button-secondary" target="_blank" rel="noopener noreferrer" 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>
303 </div>
304
305 <?php
306 /**
307 * Renders after the 'blank state' message for the order list table has rendered.
308 *
309 * @since 6.6.1
310 */
311 do_action( 'wc_marketplace_suggestions_orders_empty_state' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
312 ?>
313
314 </div>
315 <?php
316 }
317
318 /**
319 * Retrieves the list of bulk actions available for this table.
320 *
321 * @return array
322 */
323 protected function get_bulk_actions() {
324 $selected_status = $this->order_query_args['status'] ?? false;
325
326 if ( ! current_user_can( $this->wp_post_type->cap->edit_others_posts ) ) {
327 return array();
328 }
329
330 if ( array( 'trash' ) === $selected_status ) {
331 $actions = array(
332 'untrash' => __( 'Restore', 'woocommerce' ),
333 'delete' => __( 'Delete permanently', 'woocommerce' ),
334 );
335 } else {
336 $actions = array(
337 'mark_processing' => __( 'Change status to processing', 'woocommerce' ),
338 'mark_on-hold' => __( 'Change status to on-hold', 'woocommerce' ),
339 'mark_completed' => __( 'Change status to completed', 'woocommerce' ),
340 'mark_cancelled' => __( 'Change status to cancelled', 'woocommerce' ),
341 'trash' => __( 'Move to Trash', 'woocommerce' ),
342 );
343 }
344
345 if ( wc_string_to_bool( get_option( 'woocommerce_allow_bulk_remove_personal_data', 'no' ) ) ) {
346 $actions['remove_personal_data'] = __( 'Remove personal data', 'woocommerce' );
347 }
348
349 return $actions;
350 }
351
352 /**
353 * Gets a list of CSS classes for the WP_List_Table table tag.
354 *
355 * @since 7.8.0
356 *
357 * @return string[] Array of CSS classes for the table tag.
358 */
359 protected function get_table_classes() {
360 /**
361 * Filters the list of CSS class names for the orders list table.
362 *
363 * @since 7.8.0
364 *
365 * @param string[] $classes An array of CSS class names.
366 * @param string $order_type The order type.
367 */
368 $css_classes = apply_filters(
369 'woocommerce_' . $this->order_type . '_list_table_css_classes',
370 array_merge(
371 parent::get_table_classes(),
372 array(
373 'wc-orders-list-table',
374 'wc-orders-list-table-' . $this->order_type,
375 )
376 ),
377 $this->order_type
378 );
379
380 return array_unique( array_map( 'trim', $css_classes ) );
381 }
382
383 /**
384 * Prepares the list of items for displaying.
385 */
386 public function prepare_items() {
387 $limit = $this->get_items_per_page( 'edit_' . $this->order_type . '_per_page' );
388
389 $this->order_query_args = array(
390 'limit' => $limit,
391 'page' => $this->get_pagenum(),
392 'paginate' => true,
393 'type' => $this->order_type,
394 );
395
396 foreach ( array( 'status', 's', 'm', '_customer_user', 'search-filter' ) as $query_var ) {
397 $this->request[ $query_var ] = sanitize_text_field( wp_unslash( $_REQUEST[ $query_var ] ?? '' ) );
398 }
399
400 /**
401 * Allows 3rd parties to filter the initial request vars before defaults and other logic is applied.
402 *
403 * @param array $request Request to be passed to `wc_get_orders()`.
404 *
405 * @since 7.3.0
406 */
407 $this->request = apply_filters( 'woocommerce_' . $this->order_type . '_list_table_request', $this->request );
408
409 $this->set_status_args();
410 $this->set_order_args();
411 $this->set_date_args();
412 $this->set_customer_args();
413 $this->set_search_args();
414 $this->set_created_via_args();
415
416 /**
417 * Provides an opportunity to modify the query arguments used in the (Custom Order Table-powered) order list
418 * table.
419 *
420 * @since 6.9.0
421 *
422 * @param array $query_args Arguments to be passed to `wc_get_orders()`.
423 */
424 $order_query_args = (array) apply_filters( 'woocommerce_order_list_table_prepare_items_query_args', $this->order_query_args );
425
426 /**
427 * Same as `woocommerce_order_list_table_prepare_items_query_args` but for a specific order type.
428 *
429 * @param array $query_args Arguments to be passed to `wc_get_orders()`.
430 *
431 * @since 7.3.0
432 */
433 $order_query_args = apply_filters( 'woocommerce_' . $this->order_type . '_list_table_prepare_items_query_args', $order_query_args );
434
435 // We must ensure the 'paginate' argument is set.
436 $order_query_args['paginate'] = true;
437
438 // Attempt to use cache if no additional query arguments are used.
439 if ( empty( array_diff( array_keys( $order_query_args ), array( 'limit', 'page', 'paginate', 'type', 'status', 'orderby', 'order' ) ) ) ) {
440 $this->order_query_args['no_found_rows'] = true;
441 $order_query_args['no_found_rows'] = true;
442 }
443
444 $orders = wc_get_orders( $order_query_args );
445 $this->items = $orders->orders;
446
447 $max_num_pages = $this->get_max_num_pages( $orders );
448
449 // Check in case the user has attempted to page beyond the available range of orders.
450 if ( 0 === $max_num_pages && $this->order_query_args['page'] > 1 ) {
451 $count_query_args = $order_query_args;
452 $count_query_args['page'] = 1;
453 $count_query_args['limit'] = 1;
454 $order_count = wc_get_orders( $count_query_args );
455 $max_num_pages = (int) ceil( $order_count->total / $order_query_args['limit'] );
456 }
457
458 $this->set_pagination_args(
459 array(
460 'total_items' => $orders->total ?? 0,
461 'per_page' => $limit,
462 'total_pages' => $max_num_pages,
463 )
464 );
465
466 // Are we inside the trash?
467 $this->is_trash = 'trash' === $this->request['status'];
468 }
469
470 /**
471 * Get the max number of pages from orders or from cache.
472 *
473 * @param WC_Order[]|stdClass Number of pages and an array of order objects.
474 * @return int
475 */
476 private function get_max_num_pages( &$orders ) {
477 if ( ! isset( $this->order_query_args['no_found_rows'] ) || ! $this->order_query_args['no_found_rows'] ) {
478 return $orders->max_num_pages;
479 }
480
481 $count = $this->count_orders_by_status( $this->order_query_args['status'] );
482 $limit = $this->get_items_per_page( 'edit_' . $this->order_type . '_per_page' );
483 $orders->total = $count;
484
485 return ceil( $count / $limit );
486 }
487
488 /**
489 * Updates the WC Order Query arguments as needed to support orderable columns.
490 */
491 private function set_order_args() {
492 $sortable = $this->get_sortable_columns();
493 $field = sanitize_text_field( wp_unslash( $_GET['orderby'] ?? '' ) );
494 $direction = strtoupper( sanitize_text_field( wp_unslash( $_GET['order'] ?? '' ) ) );
495
496 if ( ! in_array( $field, $sortable, true ) ) {
497 $this->order_query_args['orderby'] = 'date';
498 $this->order_query_args['order'] = 'DESC';
499 return;
500 }
501
502 $this->order_query_args['orderby'] = $field;
503 $this->order_query_args['order'] = in_array( $direction, array( 'ASC', 'DESC' ), true ) ? $direction : 'ASC';
504 }
505
506 /**
507 * Implements date (month-based) filtering.
508 */
509 private function set_date_args() {
510 $year_month = sanitize_text_field( wp_unslash( $_GET['m'] ?? '' ) );
511
512 if ( empty( $year_month ) || ! preg_match( '/^[0-9]{6}$/', $year_month ) ) {
513 return;
514 }
515
516 $year = (int) substr( $year_month, 0, 4 );
517 $month = (int) substr( $year_month, 4, 2 );
518
519 if ( $month < 0 || $month > 12 ) {
520 return;
521 }
522
523 $last_day_of_month = date_create( "$year-$month" )->format( 'Y-m-t' );
524 $this->order_query_args['date_created'] = "$year-$month-01..." . $last_day_of_month;
525 $this->has_filter = true;
526 }
527
528 /**
529 * Implements filtering of orders by customer.
530 */
531 private function set_customer_args() {
532 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
533 $customer = (int) wp_unslash( $_GET['_customer_user'] ?? '' );
534
535 if ( $customer < 1 ) {
536 return;
537 }
538
539 $this->order_query_args['customer'] = $customer;
540 $this->has_filter = true;
541 }
542
543 /**
544 * Implements filtering of orders by status.
545 */
546 private function set_status_args() {
547 $status = array_filter( array_map( 'trim', (array) $this->request['status'] ) );
548
549 if ( empty( $status ) || in_array( 'all', $status, true ) ) {
550 /**
551 * Allows 3rd parties to set the default list of statuses for a given order type.
552 *
553 * @param string[] $statuses Statuses.
554 *
555 * @since 7.3.0
556 */
557 $status = apply_filters(
558 'woocommerce_' . $this->order_type . '_list_table_default_statuses',
559 array_intersect(
560 array_keys( wc_get_order_statuses() ),
561 get_post_stati( array( 'show_in_admin_all_list' => true ), 'names' )
562 )
563 );
564 } else {
565 $this->has_filter = true;
566 }
567
568 $this->order_query_args['status'] = $status;
569 }
570
571 /**
572 * Implements order search.
573 */
574 private function set_search_args(): void {
575 $search_term = trim( sanitize_text_field( $this->request['s'] ) );
576
577 if ( ! empty( $search_term ) ) {
578 $this->order_query_args['s'] = $search_term;
579 $this->has_filter = true;
580
581 // 'search_filter' is inert without a search term, but setting it (the form always submits the dropdown)
582 // would disqualify the request from the cached-count fast path in prepare_items() and force a COUNT.
583 $filter = trim( sanitize_text_field( $this->request['search-filter'] ) );
584 if ( ! empty( $filter ) ) {
585 $this->order_query_args['search_filter'] = $filter;
586 }
587 }
588 }
589
590 /**
591 * Implements filtering of orders by created_via value.
592 */
593 private function set_created_via_args(): void {
594 // phpcs:disable WordPress.Security.NonceVerification.Recommended
595 $created_via = sanitize_text_field( wp_unslash( $_GET['_created_via'] ?? '' ) );
596
597 if ( empty( $created_via ) ) {
598 return;
599 }
600
601 $this->order_query_args['created_via'] = array_map( 'trim', explode( ',', $created_via ) );
602
603 $this->has_filter = true;
604 }
605
606 /**
607 * Render the created_via filter dropdown.
608 *
609 * @return void
610 */
611 public function created_via_filter() {
612 // phpcs:disable WordPress.Security.NonceVerification.Recommended
613 $current_created_via = isset( $_GET['_created_via'] ) ? sanitize_text_field( wp_unslash( $_GET['_created_via'] ) ) : '';
614
615 $created_via_options = array(
616 '' => __( 'All sales channels', 'woocommerce' ),
617 'admin' => __( 'Admin', 'woocommerce' ),
618 'checkout,store-api' => __( 'Checkout', 'woocommerce' ),
619 'pos-rest-api' => __( 'Point of Sale', 'woocommerce' ),
620 );
621 ?>
622
623 <select name="_created_via" id="filter-by-created-via">
624 <?php foreach ( $created_via_options as $value => $label ) : ?>
625 <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $value, $current_created_via ); ?>>
626 <?php echo esc_html( $label ); ?>
627 </option>
628 <?php endforeach; ?>
629 </select>
630 <?php
631 }
632
633 /**
634 * Get the list of views for this table (all orders, completed orders, etc, each with a count of the number of
635 * corresponding orders).
636 *
637 * @return array
638 */
639 public function get_views() {
640 $view_links = array();
641
642 /**
643 * Filters the list of available list table view links before the actual query runs.
644 * This can be used to, e.g., remove counts from the links.
645 *
646 * @since 8.6.0
647 *
648 * @param string[] $views An array of available list table view links.
649 */
650 $view_links = apply_filters( 'woocommerce_before_' . $this->order_type . '_list_table_view_links', $view_links );
651 if ( ! empty( $view_links ) ) {
652 return $view_links;
653 }
654
655 $view_counts = array();
656 $statuses = $this->get_visible_statuses();
657 $current = ! empty( $this->request['status'] ) ? sanitize_text_field( $this->request['status'] ) : 'all';
658 $all_count = 0;
659
660 foreach ( array_keys( $statuses ) as $slug ) {
661 $total_in_status = $this->count_orders_by_status( $slug );
662
663 if ( $total_in_status > 0 ) {
664 $view_counts[ $slug ] = $total_in_status;
665 }
666
667 if ( ( get_post_status_object( $slug ) )->show_in_admin_all_list && 'auto-draft' !== $slug ) {
668 $all_count += $total_in_status;
669 }
670 }
671
672 $view_links['all'] = $this->get_view_link( 'all', __( 'All', 'woocommerce' ), $all_count, '' === $current || 'all' === $current );
673
674 foreach ( $view_counts as $slug => $count ) {
675 $view_links[ $slug ] = $this->get_view_link( $slug, $statuses[ $slug ], $count, $slug === $current );
676 }
677
678 return $view_links;
679 }
680
681 /**
682 * Count orders by status.
683 *
684 * @param string|string[] $status The order status we are interested in.
685 *
686 * @return int
687 */
688 private function count_orders_by_status( $status ): int {
689 $status = (array) $status;
690 $counts = OrderUtil::get_count_for_type( $this->order_type );
691 $count = array_sum( array_intersect_key( $counts, array_flip( $status ) ) );
692
693 /**
694 * Allows 3rd parties to modify the count of orders by status.
695 *
696 * @param int $count Number of orders for the given status.
697 * @param string[] $status List of order statuses in the count.
698 * @since 7.3.0
699 */
700 return apply_filters(
701 'woocommerce_' . $this->order_type . '_list_table_order_count',
702 $count,
703 $status
704 );
705 }
706
707 /**
708 * Checks whether the blank state should be rendered or not. This depends on whether there are others with a visible
709 * status.
710 *
711 * @return boolean TRUE when the blank state should be rendered, FALSE otherwise.
712 */
713 private function should_render_blank_state(): bool {
714 /**
715 * Whether we should render a blank state so that custom count queries can be used.
716 *
717 * @since 8.6.0
718 *
719 * @param null $should_render_blank_state `null` will use the built-in counts. Sending a boolean will short-circuit that path.
720 * @param object ListTable The current instance of the class.
721 */
722 $should_render_blank_state = apply_filters(
723 'woocommerce_' . $this->order_type . '_list_table_should_render_blank_state',
724 null,
725 $this
726 );
727
728 if ( is_bool( $should_render_blank_state ) ) {
729 return $should_render_blank_state;
730 }
731
732 return ( ! $this->has_filter ) && 0 === $this->count_orders_by_status( array_keys( $this->get_visible_statuses() ) );
733 }
734
735 /**
736 * Returns a list of slug and labels for order statuses that should be visible in the status list.
737 *
738 * @return array slug => label array of order statuses.
739 */
740 private function get_visible_statuses(): array {
741 return array_intersect_key(
742 array_merge(
743 wc_get_order_statuses(),
744 array(
745 'trash' => ( get_post_status_object( 'trash' ) )->label,
746 'draft' => ( get_post_status_object( 'draft' ) )->label,
747 'auto-draft' => ( get_post_status_object( 'auto-draft' ) )->label,
748 )
749 ),
750 array_flip( get_post_stati( array( 'show_in_admin_status_list' => true ) ) )
751 );
752 }
753
754 /**
755 * Form a link to use in the list of table views.
756 *
757 * @param string $slug Slug used to identify the view (usually the order status slug).
758 * @param string $name Human-readable name of the view (usually the order status label).
759 * @param int $count Number of items in this view.
760 * @param bool $current If this is the current view.
761 *
762 * @return string
763 */
764 private function get_view_link( string $slug, string $name, int $count, bool $current ): string {
765 $base_url = get_admin_url( null, 'admin.php?page=wc-orders' . ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type ) );
766 $url = esc_url( add_query_arg( 'status', $slug, $base_url ) );
767 $name = esc_html( $name );
768 $count = number_format_i18n( $count );
769 $class = $current ? 'class="current"' : '';
770
771 return "<a href='$url' $class>$name <span class='count'>($count)</span></a>";
772 }
773
774 /**
775 * Extra controls to be displayed between bulk actions and pagination.
776 *
777 * @param string $which Either 'top' or 'bottom'.
778 */
779 protected function extra_tablenav( $which ) {
780 echo '<div class="alignleft actions">';
781
782 if ( 'top' === $which ) {
783 ob_start();
784
785 $this->months_filter();
786
787 /**
788 * Fires before the "Filter" button on the list table for orders and other order types.
789 *
790 * @since 7.3.0
791 *
792 * @param string $order_type The order type.
793 * @param string $which The location of the extra table nav: 'top' or 'bottom'.
794 */
795 do_action( 'woocommerce_order_list_table_restrict_manage_orders', $this->order_type, $which );
796
797 $output = ob_get_clean();
798
799 if ( ! empty( $output ) ) {
800 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
801 submit_button( __( 'Filter', 'woocommerce' ), '', 'filter_action', false, array( 'id' => 'order-query-submit' ) );
802 }
803 }
804
805 if ( $this->is_trash && $this->has_items() && current_user_can( 'edit_others_shop_orders' ) ) {
806 submit_button( __( 'Empty Trash', 'woocommerce' ), 'apply', 'delete_all', false );
807 }
808
809 /**
810 * Fires immediately following the closing "actions" div in the tablenav for the order
811 * list table.
812 *
813 * @since 7.3.0
814 *
815 * @param string $order_type The order type.
816 * @param string $which The location of the extra table nav: 'top' or 'bottom'.
817 */
818 do_action( 'woocommerce_order_list_table_extra_tablenav', $this->order_type, $which );
819
820 echo '</div>';
821 }
822
823 /**
824 * Render the months filter dropdown.
825 *
826 * @return void
827 */
828 private function months_filter() {
829 global $wp_locale;
830
831 /**
832 * Filters whether to remove the 'Months' drop-down from the order list table.
833 *
834 * @since 8.6.0
835 *
836 * @param bool $disable Whether to disable the drop-down. Default false.
837 */
838 if ( apply_filters( 'woocommerce_' . $this->order_type . '_list_table_disable_months_filter', false ) ) {
839 return;
840 }
841
842 $m = isset( $_GET['m'] ) ? (int) $_GET['m'] : 0;
843 echo '<select name="m" id="filter-by-date">';
844 echo '<option ' . selected( $m, 0, false ) . ' value="0">' . esc_html__( 'All dates', 'woocommerce' ) . '</option>';
845
846 $order_dates = $this->get_months_filter_options();
847
848 foreach ( $order_dates as $date ) {
849 $month = zeroise( $date->month, 2 );
850 $month_year_text = sprintf(
851 /* translators: 1: Month name, 2: 4-digit year. */
852 esc_html_x( '%1$s %2$d', 'order dates dropdown', 'woocommerce' ),
853 $wp_locale->get_month( $month ),
854 $date->year
855 );
856
857 printf(
858 '<option %1$s value="%2$s">%3$s</option>\n',
859 selected( $m, $date->year . $month, false ),
860 esc_attr( $date->year . $month ),
861 esc_html( $month_year_text )
862 );
863 }
864
865 echo '</select>';
866 }
867
868 /**
869 * Get a list of year-month options for filtering the orders list table.
870 *
871 * This finds the oldest order and generates a year-month option for every month in the range between then and the
872 * current month.
873 *
874 * @return \stdClass[]
875 */
876 protected function get_months_filter_options(): array {
877 global $wpdb;
878
879 $table_name = OrdersTableDataStore::get_orders_table_name();
880 $min_max_months = $wpdb->get_row(
881 $wpdb->prepare(
882 "SELECT MIN(date_created_gmt) as min_date_gmt, MAX(date_created_gmt) as max_date_gmt
883 FROM (
884 ( SELECT date_created_gmt FROM %i WHERE type = %s AND status != 'trash' ORDER BY date_created_gmt DESC LIMIT 1 )
885 UNION ALL
886 ( SELECT date_created_gmt FROM %i WHERE type = %s AND status != 'trash' ORDER BY date_created_gmt ASC LIMIT 1 )
887 ) d",
888 $table_name,
889 $this->order_type,
890 $table_name,
891 $this->order_type
892 )
893 );
894
895 /**
896 * Normalize "this month" to be the first day of the month in the current timezone of the site.
897 */
898 $this_month = new \WC_DateTime(
899 'now',
900 new \DateTimeZone( 'UTC' )
901 );
902 $this_month->setTimezone( wp_timezone() );
903 $this_month->setDate( $this_month->format( 'Y' ), $this_month->format( 'm' ), 1 );
904 $this_month->setTime( 0, 0 );
905
906 $options = array();
907
908 if ( isset( $min_max_months ) && ! is_null( $min_max_months->min_date_gmt ) ) {
909 $start = new \WC_DateTime(
910 $min_max_months->min_date_gmt,
911 new \DateTimeZone( 'UTC' )
912 );
913 $start->setTimezone( wp_timezone() );
914 $start->setDate( $start->format( 'Y' ), $start->format( 'm' ), 1 );
915 $start->setTime( 0, 0 );
916
917 $end = new \WC_DateTime(
918 $min_max_months->max_date_gmt,
919 new \DateTimeZone( 'UTC' )
920 );
921 $end->setTimezone( wp_timezone() );
922 $end->setDate( $end->format( 'Y' ), $end->format( 'm' ), 1 );
923 $end->setTime( 0, 0 );
924
925 if ( $start > $this_month ) {
926 $start = $this_month;
927 }
928
929 if ( $end < $this_month ) {
930 $end = $this_month;
931 }
932
933 $intervals = new \DatePeriod( $start, new \DateInterval( 'P1M' ), $end );
934
935 foreach ( $intervals as $interval ) {
936 $option = new \stdClass();
937 $option->year = $interval->format( 'Y' );
938 $option->month = $interval->format( 'n' );
939 $options[] = $option;
940 }
941
942 $option = new \stdClass();
943 $option->year = $end->format( 'Y' );
944 $option->month = $end->format( 'n' );
945 $options[] = $option;
946 }
947
948 if ( count( $options ) < 1 ) {
949 $option = new \stdClass();
950 $option->year = $this_month->format( 'Y' );
951 $option->month = $this_month->format( 'n' );
952 $options[] = $option;
953 }
954
955 return array_reverse( $options );
956 }
957
958 /**
959 * Get order year-months cache. We cache the results in the options table, since these results will change very infrequently.
960 * We use the heuristic to always return current year-month when getting from cache to prevent an additional query.
961 *
962 * @deprecated 9.9.0
963 *
964 * @return array List of year-months.
965 */
966 protected function get_and_maybe_update_months_filter_cache(): array {
967 wc_deprecated_function(
968 __METHOD__,
969 '9.9.0',
970 'get_months_filter_options'
971 );
972
973 return $this->get_months_filter_options();
974 }
975
976 /**
977 * Render the customer filter dropdown.
978 *
979 * @return void
980 */
981 public function customers_filter() {
982 $user_string = '';
983 $user_id = '';
984
985 // phpcs:disable WordPress.Security.NonceVerification.Recommended
986 if ( ! empty( $_GET['_customer_user'] ) ) {
987 $user_id = absint( $_GET['_customer_user'] );
988 $user = get_user_by( 'id', $user_id );
989
990 $user_string = sprintf(
991 /* translators: 1: user display name 2: user ID 3: user email */
992 esc_html__( '%1$s (#%2$s &ndash; %3$s)', 'woocommerce' ),
993 $user->display_name,
994 absint( $user->ID ),
995 $user->user_email
996 );
997 }
998
999 // Note: use of htmlspecialchars (below) is to prevent XSS when rendered by selectWoo.
1000 ?>
1001 <select class="wc-customer-search" name="_customer_user" data-placeholder="<?php esc_attr_e( 'Filter by registered customer', 'woocommerce' ); ?>" data-allow_clear="true">
1002 <option value="<?php echo esc_attr( $user_id ); ?>" selected="selected"><?php echo htmlspecialchars( wp_kses_post( $user_string ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></option>
1003 </select>
1004 <?php
1005 }
1006
1007 /**
1008 * Get list columns.
1009 *
1010 * @return array
1011 */
1012 public function get_columns() {
1013 /**
1014 * Filters the list of columns.
1015 *
1016 * @param array $columns List of sortable columns.
1017 *
1018 * @since 7.3.0
1019 */
1020 return apply_filters(
1021 'woocommerce_' . $this->order_type . '_list_table_columns',
1022 array(
1023 'cb' => '<input type="checkbox" />',
1024 'order_number' => esc_html__( 'Order', 'woocommerce' ),
1025 'order_date' => esc_html__( 'Date', 'woocommerce' ),
1026 'order_status' => esc_html__( 'Status', 'woocommerce' ),
1027 'billing_address' => esc_html__( 'Billing', 'woocommerce' ),
1028 'shipping_address' => esc_html__( 'Ship to', 'woocommerce' ),
1029 'order_total' => esc_html__( 'Total', 'woocommerce' ),
1030 'wc_actions' => esc_html__( 'Actions', 'woocommerce' ),
1031 )
1032 );
1033 }
1034
1035 /**
1036 * Defines the default sortable columns.
1037 *
1038 * @return string[]
1039 */
1040 public function get_sortable_columns() {
1041 /**
1042 * Filters the list of sortable columns.
1043 *
1044 * @param array $sortable_columns List of sortable columns.
1045 *
1046 * @since 7.3.0
1047 */
1048 return apply_filters(
1049 'woocommerce_' . $this->order_type . '_list_table_sortable_columns',
1050 array(
1051 'order_number' => 'ID',
1052 'order_date' => 'date',
1053 'order_total' => 'order_total',
1054 )
1055 );
1056 }
1057
1058 /**
1059 * Specify the columns we wish to hide by default.
1060 *
1061 * @param array $hidden Columns set to be hidden.
1062 * @param WP_Screen $screen Screen object.
1063 *
1064 * @return array
1065 */
1066 public function default_hidden_columns( array $hidden, WP_Screen $screen ) {
1067 if ( isset( $screen->id ) && wc_get_page_screen_id( 'shop-order' ) === $screen->id ) {
1068 $hidden = array_merge(
1069 $hidden,
1070 array(
1071 'billing_address',
1072 'shipping_address',
1073 'wc_actions',
1074 )
1075 );
1076 }
1077
1078 return $hidden;
1079 }
1080
1081 /**
1082 * Checklist column, used for selecting items for processing by a bulk action.
1083 *
1084 * @param WC_Order $item The order object for the current row.
1085 *
1086 * @return string
1087 */
1088 public function column_cb( $item ) {
1089 if ( ! $this->wp_post_type || ! current_user_can( $this->wp_post_type->cap->edit_post, $item->get_id() ) ) {
1090 return;
1091 }
1092
1093 ob_start();
1094 ?>
1095 <input id="cb-select-<?php echo esc_attr( $item->get_id() ); ?>" type="checkbox" name="id[]" value="<?php echo esc_attr( $item->get_id() ); ?>" />
1096
1097 <div class="locked-indicator">
1098 <span class="locked-indicator-icon" aria-hidden="true"></span>
1099 <span class="screen-reader-text">
1100 <?php
1101 // translators: %s is an order ID.
1102 echo esc_html( sprintf( __( 'Order %s is locked.', 'woocommerce' ), $item->get_id() ) );
1103 ?>
1104 </span>
1105 </div>
1106 <?php
1107 return ob_get_clean();
1108 }
1109
1110 /**
1111 * Renders the order number, customer name and provides a preview link.
1112 *
1113 * @param WC_Order $order The order object for the current row.
1114 *
1115 * @return void
1116 */
1117 public function render_order_number_column( WC_Order $order ): void {
1118 $buyer = '';
1119
1120 if ( $order->get_billing_first_name() || $order->get_billing_last_name() ) {
1121 /* translators: 1: first name 2: last name */
1122 $buyer = trim( sprintf( _x( '%1$s %2$s', 'full name', 'woocommerce' ), $order->get_billing_first_name(), $order->get_billing_last_name() ) );
1123 } elseif ( $order->get_billing_company() ) {
1124 $buyer = trim( $order->get_billing_company() );
1125 } elseif ( $order->get_customer_id() ) {
1126 $user = get_user_by( 'id', $order->get_customer_id() );
1127 $buyer = ucwords( $user->display_name );
1128 }
1129
1130 /**
1131 * Filter buyer name in list table orders.
1132 *
1133 * @since 3.7.0
1134 *
1135 * @param string $buyer Buyer name.
1136 * @param WC_Order $order Order data.
1137 */
1138 $buyer = apply_filters( 'woocommerce_admin_order_buyer_name', $buyer, $order );
1139
1140 if ( $order->get_status() === 'trash' ) {
1141 echo '<strong>#' . esc_attr( $order->get_order_number() ) . ' ' . esc_html( $buyer ) . '</strong>';
1142 } else {
1143 echo '<a href="#" class="order-preview" data-order-id="' . absint( $order->get_id() ) . '" title="' . esc_attr( __( 'Preview', 'woocommerce' ) ) . '">' . esc_html( __( 'Preview', 'woocommerce' ) ) . '</a>';
1144 echo '<a href="' . esc_url( $this->get_order_edit_link( $order ) ) . '" class="order-view"><strong>#' . esc_attr( $order->get_order_number() ) . ' ' . esc_html( $buyer ) . '</strong></a>';
1145 }
1146
1147 // Used for showing date & status next to order number/buyer name on small screens.
1148 echo '<div class="order_date small-screen-only">';
1149 $this->render_order_date_column( $order );
1150 echo '</div>';
1151 echo '<div class="order_status small-screen-only">';
1152 $this->render_order_status_column( $order );
1153 echo '</div>';
1154 }
1155
1156 /**
1157 * Get the edit link for an order.
1158 *
1159 * @param WC_Order $order Order object.
1160 *
1161 * @return string Edit link for the order.
1162 */
1163 private function get_order_edit_link( WC_Order $order ): string {
1164 return $this->page_controller->get_edit_url( $order->get_id() );
1165 }
1166
1167 /**
1168 * Renders the order date.
1169 *
1170 * @param WC_Order $order The order object for the current row.
1171 *
1172 * @return void
1173 */
1174 public function render_order_date_column( WC_Order $order ): void {
1175 $order_timestamp = $order->get_date_created() ? $order->get_date_created()->getTimestamp() : '';
1176
1177 if ( ! $order_timestamp ) {
1178 echo '&ndash;';
1179 return;
1180 }
1181
1182 // Check if the order was created within the last 24 hours, and not in the future.
1183 if ( $order_timestamp > strtotime( '-1 day', time() ) && $order_timestamp <= time() ) {
1184 $show_date = sprintf(
1185 /* translators: %s: human-readable time difference */
1186 _x( '%s ago', '%s = human-readable time difference', 'woocommerce' ),
1187 human_time_diff( $order->get_date_created()->getTimestamp(), time() )
1188 );
1189 } else {
1190 $show_date = $order->get_date_created()->date_i18n( apply_filters( 'woocommerce_admin_order_date_format', __( 'M j, Y', 'woocommerce' ) ) ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
1191 }
1192 printf(
1193 '<time datetime="%1$s" title="%2$s">%3$s</time>',
1194 esc_attr( $order->get_date_created()->date( 'c' ) ),
1195 esc_html( $order->get_date_created()->date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ),
1196 esc_html( $show_date )
1197 );
1198 }
1199
1200 /**
1201 * Renders the order status.
1202 *
1203 * @param WC_Order $order The order object for the current row.
1204 *
1205 * @return void
1206 */
1207 public function render_order_status_column( WC_Order $order ): void {
1208 /* translators: %s: order status label */
1209 $tooltip = wc_sanitize_tooltip( $this->get_order_status_label( $order ) );
1210
1211 // Gracefully handle legacy statuses.
1212 if ( in_array( $order->get_status(), array( 'trash', 'draft', 'auto-draft' ), true ) ) {
1213 $status_name = ( get_post_status_object( $order->get_status() ) )->label;
1214 } else {
1215 $status_name = wc_get_order_status_name( $order->get_status() );
1216 }
1217
1218 if ( $tooltip ) {
1219 printf( '<mark class="order-status %s tips" data-tip="%s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $order->get_status() ) ), wp_kses_post( $tooltip ), esc_html( $status_name ) );
1220 } else {
1221 printf( '<mark class="order-status %s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $order->get_status() ) ), esc_html( $status_name ) );
1222 }
1223 }
1224
1225 /**
1226 * Gets the order status label for an order.
1227 *
1228 * @param WC_Order $order The order object.
1229 *
1230 * @return string
1231 */
1232 private function get_order_status_label( WC_Order $order ): string {
1233 $status_names = array(
1234 'pending' => __( 'The order has been received, but no payment has been made. Pending payment orders are generally awaiting customer action.', 'woocommerce' ),
1235 'on-hold' => __( 'The order is awaiting payment confirmation. Stock is reduced, but you need to confirm payment.', 'woocommerce' ),
1236 'processing' => __( 'Payment has been received (paid), and the stock has been reduced. The order is awaiting fulfillment.', 'woocommerce' ),
1237 'completed' => __( 'Order fulfilled and complete.', 'woocommerce' ),
1238 'failed' => __( 'The customer’s payment failed or was declined, and no payment has been successfully made.', 'woocommerce' ),
1239 'checkout-draft' => __( 'Draft orders are created when customers start the checkout process while the block version of the checkout is in place.', 'woocommerce' ),
1240 'cancelled' => __( 'The order was canceled by an admin or the customer.', 'woocommerce' ),
1241 'refunded' => __( 'Orders are automatically put in the Refunded status when an admin or shop manager has fully refunded the order’s value after payment.', 'woocommerce' ),
1242 );
1243
1244 /**
1245 * Provides an opportunity to modify and extend the order status labels.
1246 *
1247 * @param array $action Order actions.
1248 * @param WC_Order $order Current order object.
1249 * @since 9.1.0
1250 */
1251 $status_names = apply_filters( 'woocommerce_get_order_status_labels', $status_names, $order );
1252
1253 $status_name = $order->get_status();
1254
1255 return isset( $status_names[ $status_name ] ) ? $status_names[ $status_name ] : '';
1256 }
1257
1258 /**
1259 * Renders order billing information.
1260 *
1261 * @param WC_Order $order The order object for the current row.
1262 *
1263 * @return void
1264 */
1265 public function render_billing_address_column( WC_Order $order ): void {
1266 $address = $order->get_formatted_billing_address();
1267
1268 if ( $address ) {
1269 echo esc_html( preg_replace( '#<br\s*/?>#i', ', ', $address ) );
1270
1271 if ( $order->get_payment_method() ) {
1272 /* translators: %s: payment method */
1273 echo '<span class="description">' . sprintf( esc_html__( 'via %s', 'woocommerce' ), esc_html( $order->get_payment_method_title() ) ) . '</span>';
1274 }
1275 } else {
1276 echo '&ndash;';
1277 }
1278 }
1279
1280 /**
1281 * Renders order shipping information.
1282 *
1283 * @param WC_Order $order The order object for the current row.
1284 *
1285 * @return void
1286 */
1287 public function render_shipping_address_column( WC_Order $order ): void {
1288 $address = $order->get_formatted_shipping_address();
1289
1290 if ( $address ) {
1291 echo '<a target="_blank" href="' . esc_url( $order->get_shipping_address_map_url() ) . '">' . esc_html( preg_replace( '#<br\s*/?>#i', ', ', $address ) ) . '</a>';
1292 if ( $order->get_shipping_method() ) {
1293 /* translators: %s: shipping method */
1294 echo '<span class="description">' . sprintf( esc_html__( 'via %s', 'woocommerce' ), esc_html( $order->get_shipping_method() ) ) . '</span>';
1295 }
1296 } else {
1297 echo '&ndash;';
1298 }
1299 }
1300
1301 /**
1302 * Renders the order total.
1303 *
1304 * @param WC_Order $order The order object for the current row.
1305 *
1306 * @return void
1307 */
1308 public function render_order_total_column( WC_Order $order ): void {
1309 if ( $order->get_payment_method_title() ) {
1310 /* translators: %s: method */
1311 echo '<span class="tips" data-tip="' . esc_attr( sprintf( __( 'via %s', 'woocommerce' ), $order->get_payment_method_title() ) ) . '">' . wp_kses_post( $order->get_formatted_order_total() ) . '</span>';
1312 } else {
1313 echo wp_kses_post( $order->get_formatted_order_total() );
1314 }
1315 }
1316
1317 /**
1318 * Renders order actions.
1319 *
1320 * @param WC_Order $order The order object for the current row.
1321 *
1322 * @return void
1323 */
1324 public function render_wc_actions_column( WC_Order $order ): void {
1325 echo '<p>';
1326
1327 /**
1328 * Fires before the order action buttons (within the actions column for the order list table)
1329 * are registered.
1330 *
1331 * @param WC_Order $order Current order object.
1332 * @since 6.7.0
1333 */
1334 do_action( 'woocommerce_admin_order_actions_start', $order );
1335
1336 $actions = array();
1337
1338 if ( $order->has_status( array( 'pending', 'on-hold' ) ) ) {
1339 $actions['processing'] = array(
1340 'url' => wp_nonce_url( admin_url( 'admin-ajax.php?action=woocommerce_mark_order_status&status=processing&order_id=' . $order->get_id() ), 'woocommerce-mark-order-status' ),
1341 'name' => __( 'Processing', 'woocommerce' ),
1342 'action' => 'processing',
1343 );
1344 }
1345
1346 if ( $order->has_status( array( 'pending', 'on-hold', 'processing' ) ) ) {
1347 $actions['complete'] = array(
1348 'url' => wp_nonce_url( admin_url( 'admin-ajax.php?action=woocommerce_mark_order_status&status=completed&order_id=' . $order->get_id() ), 'woocommerce-mark-order-status' ),
1349 'name' => __( 'Complete', 'woocommerce' ),
1350 'action' => 'complete',
1351 );
1352 }
1353
1354 /**
1355 * Provides an opportunity to modify the action buttons within the order list table.
1356 *
1357 * @param array $action Order actions.
1358 * @param WC_Order $order Current order object.
1359 * @since 6.7.0
1360 */
1361 $actions = apply_filters( 'woocommerce_admin_order_actions', $actions, $order );
1362
1363 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1364 echo wc_render_action_buttons( $actions );
1365
1366 /**
1367 * Fires after the order action buttons (within the actions column for the order list table)
1368 * are rendered.
1369 *
1370 * @param WC_Order $order Current order object.
1371 * @since 6.7.0
1372 */
1373 do_action( 'woocommerce_admin_order_actions_end', $order );
1374
1375 echo '</p>';
1376 }
1377
1378 /**
1379 * Outputs hidden fields used to retain state when filtering.
1380 *
1381 * @return void
1382 */
1383 private function print_hidden_form_fields(): void {
1384 echo '<input type="hidden" name="page" value="wc-orders' . ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type ) . '" >'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1385
1386 $state_params = array(
1387 'paged',
1388 'status',
1389 );
1390
1391 foreach ( $state_params as $param ) {
1392 if ( ! isset( $_GET[ $param ] ) ) {
1393 continue;
1394 }
1395
1396 echo '<input type="hidden" name="' . esc_attr( $param ) . '" value="' . esc_attr( sanitize_text_field( wp_unslash( $_GET[ $param ] ) ) ) . '" >';
1397 }
1398 }
1399
1400 /**
1401 * Gets the current action selected from the bulk actions dropdown.
1402 *
1403 * @return string|false The action name. False if no action was selected.
1404 */
1405 public function current_action() {
1406 if ( ! empty( $_REQUEST['delete_all'] ) ) {
1407 return 'delete_all';
1408 }
1409
1410 return parent::current_action();
1411 }
1412
1413 /**
1414 * Handle bulk actions.
1415 */
1416 public function handle_bulk_actions() {
1417 $action = $this->current_action();
1418
1419 if ( ! $action || ! current_user_can( $this->wp_post_type->cap->edit_others_posts ) ) {
1420 return;
1421 }
1422
1423 check_admin_referer( 'bulk-orders' );
1424
1425 $redirect_to = remove_query_arg( array( 'deleted', 'ids' ), wp_get_referer() );
1426 $redirect_to = add_query_arg( 'paged', $this->get_pagenum(), $redirect_to );
1427
1428 if ( 'delete_all' === $action ) {
1429 // Get all trashed orders.
1430 $ids = wc_get_orders(
1431 array(
1432 'type' => $this->order_type,
1433 'status' => 'trash',
1434 'limit' => -1,
1435 'return' => 'ids',
1436 )
1437 );
1438
1439 $action = 'delete';
1440 } else {
1441 $ids = isset( $_REQUEST['id'] ) ? array_reverse( array_map( 'absint', (array) $_REQUEST['id'] ) ) : array();
1442 }
1443
1444 /**
1445 * Allows 3rd parties to modify order IDs about to be affected by a bulk action.
1446 *
1447 * @param array Array of order IDs.
1448 */
1449 $ids = apply_filters( // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
1450 'woocommerce_bulk_action_ids',
1451 $ids,
1452 $action,
1453 'order'
1454 );
1455
1456 if ( ! $ids ) {
1457 wp_safe_redirect( $redirect_to );
1458 exit;
1459 }
1460
1461 $report_action = '';
1462 $changed = 0;
1463 $action_handled = true;
1464
1465 if ( 'remove_personal_data' === $action ) {
1466 $report_action = 'removed_personal_data';
1467 $changed = $this->do_bulk_action_remove_personal_data( $ids );
1468 } elseif ( 'trash' === $action ) {
1469 $changed = $this->do_delete( $ids );
1470 $report_action = 'trashed';
1471 } elseif ( 'delete' === $action ) {
1472 $changed = $this->do_delete( $ids, true );
1473 $report_action = 'deleted';
1474 } elseif ( 'untrash' === $action ) {
1475 $changed = $this->do_untrash( $ids );
1476 $report_action = 'untrashed';
1477 } elseif ( false !== strpos( $action, 'mark_' ) ) {
1478 $order_statuses = wc_get_order_statuses();
1479 $new_status = substr( $action, 5 );
1480 $report_action = 'marked_' . $new_status;
1481
1482 if ( isset( $order_statuses[ 'wc-' . $new_status ] ) ) {
1483 $changed = $this->do_bulk_action_mark_orders( $ids, $new_status );
1484 } else {
1485 $action_handled = false;
1486 }
1487 } else {
1488 $action_handled = false;
1489 }
1490
1491 // Custom action.
1492 if ( ! $action_handled ) {
1493 $screen = get_current_screen()->id;
1494
1495 /**
1496 * This action is documented in /wp-admin/edit.php (it is a core WordPress hook).
1497 *
1498 * @since 7.2.0
1499 *
1500 * @param string $redirect_to The URL to redirect to after processing the bulk actions.
1501 * @param string $action The current bulk action.
1502 * @param int[] $ids IDs for the orders to be processed.
1503 */
1504 $custom_sendback = apply_filters( "handle_bulk_actions-{$screen}", $redirect_to, $action, $ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
1505 }
1506
1507 if ( ! empty( $custom_sendback ) ) {
1508 $redirect_to = $custom_sendback;
1509 } elseif ( $changed ) {
1510 $redirect_to = add_query_arg(
1511 array(
1512 'bulk_action' => $report_action,
1513 'changed' => $changed,
1514 'ids' => implode( ',', $ids ),
1515 ),
1516 $redirect_to
1517 );
1518 }
1519
1520 wp_safe_redirect( $redirect_to );
1521 exit;
1522 }
1523
1524 /**
1525 * Implements the "remove personal data" bulk action.
1526 *
1527 * @param array $order_ids The Order IDs.
1528 * @return int Number of orders modified.
1529 */
1530 private function do_bulk_action_remove_personal_data( $order_ids ): int {
1531 $changed = 0;
1532
1533 foreach ( $order_ids as $id ) {
1534 $order = wc_get_order( $id );
1535
1536 if ( ! $order ) {
1537 continue;
1538 }
1539
1540 do_action( 'woocommerce_remove_order_personal_data', $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
1541 ++$changed;
1542 }
1543
1544 return $changed;
1545 }
1546
1547 /**
1548 * Implements the "mark <status>" bulk action.
1549 *
1550 * @param array $order_ids The order IDs to change.
1551 * @param string $new_status The new order status.
1552 * @return int Number of orders modified.
1553 */
1554 private function do_bulk_action_mark_orders( $order_ids, $new_status ): int {
1555 $changed = 0;
1556
1557 // Initialize payment gateways in case order has hooked status transition actions.
1558 WC()->payment_gateways();
1559
1560 foreach ( $order_ids as $id ) {
1561 $order = wc_get_order( $id );
1562
1563 if ( ! $order ) {
1564 continue;
1565 }
1566
1567 $order->update_status( $new_status, __( 'Order status changed by bulk edit.', 'woocommerce' ), true );
1568 do_action( 'woocommerce_order_edit_status', $id, $new_status ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
1569 ++$changed;
1570 }
1571
1572 return $changed;
1573 }
1574
1575 /**
1576 * Handles bulk trashing of orders.
1577 *
1578 * @param int[] $ids Order IDs to be trashed.
1579 * @param bool $force_delete When set, the order will be completed deleted. Otherwise, it will be trashed.
1580 *
1581 * @return int Number of orders that were trashed.
1582 */
1583 private function do_delete( array $ids, bool $force_delete = false ): int {
1584 $changed = 0;
1585
1586 foreach ( $ids as $id ) {
1587 $order = wc_get_order( $id );
1588 $order->delete( $force_delete );
1589 $updated_order = wc_get_order( $id );
1590
1591 if ( ( $force_delete && false === $updated_order ) || ( ! $force_delete && $updated_order->get_status() === 'trash' ) ) {
1592 ++$changed;
1593 }
1594 }
1595
1596 return $changed;
1597 }
1598
1599 /**
1600 * Handles bulk restoration of trashed orders.
1601 *
1602 * @param array $ids Order IDs to be restored to their previous status.
1603 *
1604 * @return int Number of orders that were restored from the trash.
1605 */
1606 private function do_untrash( array $ids ): int {
1607 $orders_store = wc_get_container()->get( OrdersTableDataStore::class );
1608 $changed = 0;
1609
1610 foreach ( $ids as $id ) {
1611 if ( $orders_store->untrash_order( wc_get_order( $id ) ) ) {
1612 ++$changed;
1613 }
1614 }
1615
1616 return $changed;
1617 }
1618
1619 /**
1620 * Show confirmation message that order status changed for number of orders.
1621 */
1622 public function bulk_action_notices() {
1623 if ( empty( $_REQUEST['bulk_action'] ) ) {
1624 return;
1625 }
1626
1627 $order_statuses = wc_get_order_statuses();
1628 $number = absint( $_REQUEST['changed'] ?? 0 );
1629 $bulk_action = wc_clean( wp_unslash( $_REQUEST['bulk_action'] ) );
1630 $message = '';
1631
1632 // Check if any status changes happened.
1633 foreach ( $order_statuses as $slug => $name ) {
1634 if ( 'marked_' . str_replace( 'wc-', '', $slug ) === $bulk_action ) { // WPCS: input var ok, CSRF ok.
1635 /* translators: %s: orders count */
1636 $message = sprintf( _n( '%s order status changed.', '%s order statuses changed.', $number, 'woocommerce' ), number_format_i18n( $number ) );
1637 break;
1638 }
1639 }
1640
1641 switch ( $bulk_action ) {
1642 case 'removed_personal_data':
1643 /* translators: %s: orders count */
1644 $message = sprintf( _n( 'Removed personal data from %s order.', 'Removed personal data from %s orders.', $number, 'woocommerce' ), number_format_i18n( $number ) );
1645 echo '<div class="updated"><p>' . esc_html( $message ) . '</p></div>';
1646 break;
1647
1648 case 'trashed':
1649 /* translators: %s: orders count */
1650 $message = sprintf( _n( '%s order moved to the Trash.', '%s orders moved to the Trash.', $number, 'woocommerce' ), number_format_i18n( $number ) );
1651 break;
1652
1653 case 'untrashed':
1654 /* translators: %s: orders count */
1655 $message = sprintf( _n( '%s order restored from the Trash.', '%s orders restored from the Trash.', $number, 'woocommerce' ), number_format_i18n( $number ) );
1656 break;
1657
1658 case 'deleted':
1659 /* translators: %s: orders count */
1660 $message = sprintf( _n( '%s order permanently deleted.', '%s orders permanently deleted.', $number, 'woocommerce' ), number_format_i18n( $number ) );
1661 break;
1662 }
1663
1664 if ( ! empty( $message ) ) {
1665 echo '<div class="updated"><p>' . esc_html( $message ) . '</p></div>';
1666 }
1667 }
1668
1669 /**
1670 * Enqueue list table scripts.
1671 *
1672 * @return void
1673 */
1674 public function enqueue_scripts(): void {
1675 echo $this->get_order_preview_template(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1676 wp_enqueue_script( 'wc-orders' );
1677 }
1678
1679 /**
1680 * Returns the HTML for the order preview template.
1681 *
1682 * @return string HTML template.
1683 */
1684 public function get_order_preview_template(): string {
1685 $order_edit_url_placeholder =
1686 wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled()
1687 ? esc_url( admin_url( 'admin.php?page=wc-orders&action=edit' ) ) . '&id={{ data.data.id }}'
1688 : esc_url( admin_url( 'post.php?action=edit' ) ) . '&post={{ data.data.id }}';
1689
1690 ob_start();
1691 ?>
1692 <script type="text/template" id="tmpl-wc-modal-view-order">
1693 <div class="wc-backbone-modal wc-order-preview">
1694 <div class="wc-backbone-modal-content">
1695 <section class="wc-backbone-modal-main" role="main">
1696 <header class="wc-backbone-modal-header">
1697 <mark class="order-status status-{{ data.status }}"><span>{{ data.status_name }}</span></mark>
1698 <?php /* translators: %s: order ID */ ?>
1699 <h1><?php echo esc_html( sprintf( __( 'Order #%s', 'woocommerce' ), '{{ data.order_number }}' ) ); ?></h1>
1700 <button class="modal-close modal-close-link dashicons dashicons-no-alt">
1701 <span class="screen-reader-text"><?php esc_html_e( 'Close modal panel', 'woocommerce' ); ?></span>
1702 </button>
1703 </header>
1704 <article>
1705 <?php do_action( 'woocommerce_admin_order_preview_start' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>
1706
1707 <div class="wc-order-preview-addresses">
1708 <div class="wc-order-preview-address">
1709 <h2><?php esc_html_e( 'Billing details', 'woocommerce' ); ?></h2>
1710 {{{ data.formatted_billing_address }}}
1711
1712 <# if ( data.data.billing.email ) { #>
1713 <strong><?php esc_html_e( 'Email', 'woocommerce' ); ?></strong>
1714 <a href="mailto:{{ data.data.billing.email }}">{{ data.data.billing.email }}</a>
1715 <# } #>
1716
1717 <# if ( data.data.billing.phone ) { #>
1718 <strong><?php esc_html_e( 'Phone', 'woocommerce' ); ?></strong>
1719 <a href="tel:{{ data.data.billing.phone }}">{{ data.data.billing.phone }}</a>
1720 <# } #>
1721
1722 <# if ( data.payment_via ) { #>
1723 <strong><?php esc_html_e( 'Payment via', 'woocommerce' ); ?></strong>
1724 {{{ data.payment_via }}}
1725 <# } #>
1726 </div>
1727 <# if ( data.needs_shipping ) { #>
1728 <div class="wc-order-preview-address">
1729 <h2><?php esc_html_e( 'Shipping details', 'woocommerce' ); ?></h2>
1730 <# if ( data.ship_to_billing ) { #>
1731 {{{ data.formatted_billing_address }}}
1732 <# } else { #>
1733 <a href="{{ data.shipping_address_map_url }}" target="_blank">{{{ data.formatted_shipping_address }}}</a>
1734 <# } #>
1735
1736 <# if ( data.data.shipping.phone ) { #>
1737 <strong><?php esc_html_e( 'Phone', 'woocommerce' ); ?></strong>
1738 <a href="tel:{{ data.data.shipping.phone }}">{{ data.data.shipping.phone }}</a>
1739 <# } #>
1740
1741 <# if ( data.shipping_via ) { #>
1742 <strong><?php esc_html_e( 'Shipping method', 'woocommerce' ); ?></strong>
1743 {{ data.shipping_via }}
1744 <# } #>
1745 </div>
1746 <# } #>
1747
1748 <# if ( data.data.customer_note ) { #>
1749 <div class="wc-order-preview-note">
1750 <strong><?php esc_html_e( 'Note', 'woocommerce' ); ?></strong>
1751 {{ data.data.customer_note }}
1752 </div>
1753 <# } #>
1754 </div>
1755
1756 {{{ data.item_html }}}
1757
1758 <?php do_action( 'woocommerce_admin_order_preview_end' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>
1759 </article>
1760 <# if ( data.actions_html || data.is_editable ) { #>
1761 <footer>
1762 <div class="inner">
1763 {{{ data.actions_html }}}
1764
1765 <# if ( data.is_editable ) { #>
1766 <div class="wc-backbone-modal-buttons">
1767 <a class="button button-primary button-large" aria-label="<?php esc_attr_e( 'Edit this order', 'woocommerce' ); ?>" href="<?php echo $order_edit_url_placeholder; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><?php esc_html_e( 'Edit', 'woocommerce' ); ?></a>
1768 </div>
1769 <# } #>
1770 </div>
1771 </footer>
1772 <# } #>
1773 </section>
1774 </div>
1775 </div>
1776 <div class="wc-backbone-modal-backdrop modal-close"></div>
1777 </script>
1778 <?php
1779
1780 $html = ob_get_clean();
1781
1782 return $html;
1783 }
1784
1785 /**
1786 * Renders the search box with various options to limit order search results.
1787 *
1788 * @param string $text The search button text.
1789 * @param string $input_id The search input ID.
1790 *
1791 * @return void
1792 */
1793 public function search_box( $text, $input_id ) {
1794 if ( empty( $_REQUEST['s'] ) && ! $this->has_items() ) {
1795 return;
1796 }
1797
1798 $input_id = $input_id . '-search-input';
1799
1800 if ( ! empty( $_REQUEST['orderby'] ) ) {
1801 echo '<input type="hidden" name="orderby" value="' . esc_attr( sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ) ) ) . '" />';
1802 }
1803 if ( ! empty( $_REQUEST['order'] ) ) {
1804 echo '<input type="hidden" name="order" value="' . esc_attr( sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) ) . '" />';
1805 }
1806 ?>
1807 <p class="search-box">
1808 <label class="screen-reader-text" for="<?php echo esc_attr( $input_id ); ?>"><?php echo esc_html( $text ); ?>:</label>
1809 <input type="search" id="<?php echo esc_attr( $input_id ); ?>" name="s" value="<?php _admin_search_query(); ?>" />
1810 <?php $this->search_filter(); ?>
1811 <?php submit_button( $text, '', '', false, array( 'id' => 'search-submit' ) ); ?>
1812 </p>
1813 <?php
1814 }
1815
1816 /**
1817 * Renders the search filter dropdown.
1818 *
1819 * @return void
1820 */
1821 private function search_filter() {
1822 $options = array(
1823 'order_id' => __( 'Order ID', 'woocommerce' ),
1824 'customer_email' => __( 'Customer Email', 'woocommerce' ),
1825 'customers' => __( 'Customers', 'woocommerce' ),
1826 'products' => __( 'Products', 'woocommerce' ),
1827 'all' => __( 'All', 'woocommerce' ),
1828 );
1829
1830 /**
1831 * Filters the search filters available in the admin order search. Can be used to add new or remove existing filters.
1832 * When adding new filters, `woocommerce_hpos_generate_where_for_search_filter` should also be used to generate the WHERE clause for the new filter
1833 *
1834 * @since 8.9.0.
1835 *
1836 * @param $options array List of available filters.
1837 */
1838 $options = apply_filters( 'woocommerce_hpos_admin_search_filters', $options );
1839 $saved_setting = get_user_setting( 'wc-search-filter-hpos-admin', 'all' );
1840 $selected = sanitize_text_field( wp_unslash( $_REQUEST['search-filter'] ?? $saved_setting ) );
1841 if ( $saved_setting !== $selected ) {
1842 set_user_setting( 'wc-search-filter-hpos-admin', $selected );
1843 }
1844 ?>
1845 <select name="search-filter" id="order-search-filter">
1846 <?php foreach ( $options as $value => $label ) { ?>
1847 <option value="<?php echo esc_attr( wp_unslash( sanitize_text_field( $value ) ) ); ?>" <?php selected( $value, sanitize_text_field( wp_unslash( $selected ) ) ); ?>><?php echo esc_html( $label ); ?></option>
1848 <?php } ?>
1849 </select>
1850 <?php
1851 }
1852 }
1853