-

+

diff --git a/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php b/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php index 1630d059b2c..76a0418a68f 100644 --- a/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php +++ b/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php @@ -1060,6 +1060,12 @@ if ( 0 < $mu_plugins_count ) : + | + + + + + diff --git a/plugins/woocommerce/includes/class-wc-auth.php b/plugins/woocommerce/includes/class-wc-auth.php index 099282c7a62..9614234b53f 100644 --- a/plugins/woocommerce/includes/class-wc-auth.php +++ b/plugins/woocommerce/includes/class-wc-auth.php @@ -333,7 +333,7 @@ class WC_Auth { */ // Check if Jetpack is installed and activated. - if ( class_exists( 'Jetpack' ) && Jetpack::connection()->is_active() ) { + if ( class_exists( 'Jetpack' ) && Jetpack::connection()->has_connected_owner() ) { // Check if the user is using the WordPress.com SSO. if ( Jetpack::is_module_active( 'sso' ) ) { @@ -341,7 +341,7 @@ class WC_Auth { $redirect_url = $this->build_url( $data, 'authorize' ); // Build the SSO URL. - $login_url = Jetpack_SSO::get_instance()->build_sso_button_url( + $login_url = \Automattic\Jetpack\Connection\SSO::get_instance()->build_sso_button_url( array( 'redirect_to' => rawurlencode( esc_url_raw( $redirect_url ) ), 'action' => 'login', diff --git a/plugins/woocommerce/includes/class-wc-cache-helper.php b/plugins/woocommerce/includes/class-wc-cache-helper.php index d717cf874e9..72c037b1092 100644 --- a/plugins/woocommerce/includes/class-wc-cache-helper.php +++ b/plugins/woocommerce/includes/class-wc-cache-helper.php @@ -129,7 +129,18 @@ class WC_Cache_Helper { $location['state'] = $customer->get_billing_state(); $location['postcode'] = $customer->get_billing_postcode(); $location['city'] = $customer->get_billing_city(); - return apply_filters( 'woocommerce_geolocation_ajax_get_location_hash', substr( md5( implode( '', $location ) ), 0, 12 ), $location, $customer ); + $location_hash = substr( md5( strtolower( implode( '', $location ) ) ), 0, 12 ); + + /** + * Controls the location hash used in geolocation-based caching. + * + * @since 3.6.0 + * + * @param string $location_hash The hash used for geolocation. + * @param array $location The location/address data. + * @param WC_Customer $customer The current customer object. + */ + return apply_filters( 'woocommerce_geolocation_ajax_get_location_hash', $location_hash, $location, $customer ); } /** diff --git a/plugins/woocommerce/includes/class-wc-cart.php b/plugins/woocommerce/includes/class-wc-cart.php index 6b036136f8a..db8fefc926b 100644 --- a/plugins/woocommerce/includes/class-wc-cart.php +++ b/plugins/woocommerce/includes/class-wc-cart.php @@ -1170,7 +1170,7 @@ class WC_Cart extends WC_Legacy_Cart { $message = apply_filters( 'woocommerce_cart_product_cannot_add_another_message', $message, $product_data ); $wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : ''; - throw new Exception( sprintf( '%s %s', wc_get_cart_url(), esc_attr( $wp_button_class ), __( 'View cart', 'woocommerce' ), $message ) ); + throw new Exception( sprintf( '%s %s', $message, wc_get_cart_url(), esc_attr( $wp_button_class ), __( 'View cart', 'woocommerce' ) ) ); } } @@ -1232,12 +1232,12 @@ class WC_Cart extends WC_Legacy_Cart { $wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : ''; $message = sprintf( - '%s %s', + '%s %s', + /* translators: 1: quantity in stock 2: current quantity */ + sprintf( __( 'You cannot add that amount to the cart — we have %1$s in stock and you already have %2$s in your cart.', 'woocommerce' ), wc_format_stock_quantity_for_display( $stock_quantity, $product_data ), wc_format_stock_quantity_for_display( $stock_quantity_in_cart, $product_data ) ), wc_get_cart_url(), esc_attr( $wp_button_class ), - __( 'View cart', 'woocommerce' ), - /* translators: 1: quantity in stock 2: current quantity */ - sprintf( __( 'You cannot add that amount to the cart — we have %1$s in stock and you already have %2$s in your cart.', 'woocommerce' ), wc_format_stock_quantity_for_display( $stock_quantity, $product_data ), wc_format_stock_quantity_for_display( $stock_quantity_in_cart, $product_data ) ) + __( 'View cart', 'woocommerce' ) ); /** diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php index 943746642e8..3c920e598a4 100644 --- a/plugins/woocommerce/includes/class-wc-checkout.php +++ b/plugins/woocommerce/includes/class-wc-checkout.php @@ -1357,7 +1357,7 @@ class WC_Checkout { if ( is_callable( array( $customer_object, "get_$input" ) ) ) { $value = $customer_object->{"get_$input"}(); - } elseif ( $customer_object->meta_exists( $input ) ) { + } elseif ( is_callable( array( $customer_object, 'meta_exists' ) ) && $customer_object->meta_exists( $input ) ) { $value = $customer_object->get_meta( $input, true ); } diff --git a/plugins/woocommerce/includes/class-wc-cli.php b/plugins/woocommerce/includes/class-wc-cli.php index c30255ac558..0165be435e0 100644 --- a/plugins/woocommerce/includes/class-wc-cli.php +++ b/plugins/woocommerce/includes/class-wc-cli.php @@ -34,6 +34,7 @@ class WC_CLI { require_once __DIR__ . '/cli/class-wc-cli-tracker-command.php'; require_once __DIR__ . '/cli/class-wc-cli-com-command.php'; require_once __DIR__ . '/cli/class-wc-cli-com-extension-command.php'; + $this->maybe_include_blueprint_cli(); } /** @@ -50,6 +51,24 @@ class WC_CLI { WP_CLI::add_hook( 'after_wp_load', array( $cli_runner, 'register_commands' ) ); $cli_runner = wc_get_container()->get( ProductAttributesLookupCLIRunner::class ); WP_CLI::add_hook( 'after_wp_load', fn() => \WP_CLI::add_command( 'wc palt', $cli_runner ) ); + + if ( class_exists( \Automattic\WooCommerce\Blueprint\Cli::class ) ) { + WP_CLI::add_hook( 'after_wp_load', 'Automattic\WooCommerce\Blueprint\Cli::register_commands' ); + } + } + + /** + * Include Blueprint CLI if it's available. + */ + private function maybe_include_blueprint_cli() { + if ( ! function_exists( 'wc_admin_get_feature_config' ) ) { + require_once WC_ABSPATH . 'includes/react-admin/feature-config.php'; + } + + $features = wc_admin_get_feature_config(); + if ( isset( $features['blueprint'] ) ) { + require_once dirname( WC_PLUGIN_FILE ) . '/vendor/woocommerce/blueprint/src/Cli.php'; + } } } diff --git a/plugins/woocommerce/includes/class-wc-coupon.php b/plugins/woocommerce/includes/class-wc-coupon.php index f1148391978..5c7691cf092 100644 --- a/plugins/woocommerce/includes/class-wc-coupon.php +++ b/plugins/woocommerce/includes/class-wc-coupon.php @@ -13,7 +13,7 @@ use Automattic\WooCommerce\Utilities\StringUtil; defined( 'ABSPATH' ) || exit; -require_once dirname( __FILE__ ) . '/legacy/class-wc-legacy-coupon.php'; +require_once __DIR__ . '/legacy/class-wc-legacy-coupon.php'; /** * Coupon class. @@ -85,7 +85,7 @@ class WC_Coupon extends WC_Legacy_Coupon { * Error message. * * This property should not be considered public API, and should not be accessed directly. - * It is being added to supress PHP > 8.0 warnings against dynamic property creation, and all access + * It is being added to suppress PHP > 8.0 warnings against dynamic property creation, and all access * should be through the getter and setter methods, namely `get_error_message()` and `set_error_message()`. * In the future, the access modifier may be changed back to protected. * @@ -1066,7 +1066,7 @@ class WC_Coupon extends WC_Legacy_Coupon { $err = __( 'Sorry, this coupon is not applicable to your cart contents.', 'woocommerce' ); break; case self::E_WC_COUPON_USAGE_LIMIT_COUPON_STUCK: - if ( is_user_logged_in() && wc_get_page_id( 'myaccount' ) > 0 ) { + if ( is_user_logged_in() && wc_get_page_id( 'myaccount' ) > 0 && ! WC()->is_store_api_request() ) { /* translators: %s: myaccount page link. */ $err = sprintf( __( 'Coupon usage limit has been reached. If you were using this coupon just now but your order was not complete, you can retry or cancel the order by going to the my account page.', 'woocommerce' ), wc_get_endpoint_url( 'orders', '', wc_get_page_permalink( 'myaccount' ) ) ); } else { diff --git a/plugins/woocommerce/includes/class-wc-download-handler.php b/plugins/woocommerce/includes/class-wc-download-handler.php index 82571a9cf65..cb9d5d940a0 100644 --- a/plugins/woocommerce/includes/class-wc-download-handler.php +++ b/plugins/woocommerce/includes/class-wc-download-handler.php @@ -8,12 +8,19 @@ * @version 2.2.0 */ +use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; defined( 'ABSPATH' ) || exit; /** * Download handler class. */ class WC_Download_Handler { + use AccessiblePrivateMethods; + + /** + * The hook used for deferred tracking of partial download attempts. + */ + public const TRACK_DOWNLOAD_CALLBACK = 'track_partial_download'; /** * Hook in methods. @@ -25,6 +32,7 @@ class WC_Download_Handler { add_action( 'woocommerce_download_file_redirect', array( __CLASS__, 'download_file_redirect' ), 10, 2 ); add_action( 'woocommerce_download_file_xsendfile', array( __CLASS__, 'download_file_xsendfile' ), 10, 2 ); add_action( 'woocommerce_download_file_force', array( __CLASS__, 'download_file_force' ), 10, 2 ); + self::add_action( self::TRACK_DOWNLOAD_CALLBACK, array( __CLASS__, 'track_download' ), 10, 3 ); } /** @@ -135,9 +143,13 @@ class WC_Download_Handler { // Track the download in logs and change remaining/counts. $current_user_id = get_current_user_id(); $ip_address = WC_Geolocation::get_ip_address(); - if ( ! $download_range['is_range_request'] ) { - $download->track_download( $current_user_id > 0 ? $current_user_id : null, ! empty( $ip_address ) ? $ip_address : null ); - } + + self::track_download( + $download, + $current_user_id > 0 ? $current_user_id : null, + ! empty( $ip_address ) ? $ip_address : null, + $download_range['is_range_request'] + ); self::download( $file_path, $download->get_product_id() ); } @@ -695,6 +707,76 @@ class WC_Download_Handler { } wp_die( $message, $title, array( 'response' => $status ) ); // WPCS: XSS ok. } + + /** + * Takes care of tracking download requests, with support for deferring tracking in the case of + * partial (ranged request) downloads. + * + * @param WC_Customer_Download|int $download The download to be tracked. + * @param int|null $user_id The user ID, if known. + * @param string|null $user_ip_address The download IP address, if known. + * @param bool $defer If tracking the download should be deferred. + * + * @return void + * @throws Exception If the active version of Action Scheduler is less than 3.6.0. + */ + private static function track_download( $download, $user_id = null, $user_ip_address = null, bool $defer = false ): void { + try { + // If we were supplied with an integer, convert it to a download object. + $download = new WC_Customer_Download( $download ); + + // In simple cases, we can track the download immediately. + if ( ! $defer ) { + $download->track_download( $user_id, $user_ip_address ); + return; + } + + // Counting of partial downloads may be disabled by the site operator. + if ( get_option( 'woocommerce_downloads_count_partial', 'yes' ) !== 'yes' ) { + return; + } + + /** + * Determines how long the window of time is for tracking unique download attempts, in relation to + * partial (ranged) download requests. + * + * @since 9.2.0 + * + * @param int $window_in_seconds Non-negative number of seconds. Defaults to 1800 (30 minutes). + * @param int $download_permission_id References the download permission being tracked. + */ + $window = absint( apply_filters( 'woocommerce_partial_download_tracking_window', 30 * MINUTE_IN_SECONDS, $download->get_id() ) ); + + // If we do not have Action Scheduler 3.6.0+ (this would be an unexpected scenario) then we cannot + // track partial downloads, because we require support for unique actions. + if ( version_compare( ActionScheduler_Versions::instance()->latest_version(), '3.6.0', '<' ) ) { + throw new Exception( 'Support for unique scheduled actions is not currently available.' ); + } + + as_schedule_single_action( + time() + $window, + self::TRACK_DOWNLOAD_CALLBACK, + array( + $download->get_id(), + $user_id, + $user_ip_address, + ), + 'woocommerce', + true + ); + } catch ( Exception $e ) { + wc_get_logger()->error( + 'There was a problem while tracking a product download.', + array( + 'error' => $e->getMessage(), + 'id' => $download->get_id(), + 'user_id' => $user_id, + 'ip' => $user_ip_address, + 'deferred' => $defer ? 'yes' : 'no', + ) + ); + } + } } WC_Download_Handler::init(); diff --git a/plugins/woocommerce/includes/class-wc-form-handler.php b/plugins/woocommerce/includes/class-wc-form-handler.php index aafd3b04bb0..b9575b2ef95 100644 --- a/plugins/woocommerce/includes/class-wc-form-handler.php +++ b/plugins/woocommerce/includes/class-wc-form-handler.php @@ -582,7 +582,6 @@ class WC_Form_Handler { wp_safe_redirect( wc_get_account_endpoint_url( 'payment-methods' ) ); exit(); } - } /** @@ -607,7 +606,6 @@ class WC_Form_Handler { wp_safe_redirect( wc_get_account_endpoint_url( 'payment-methods' ) ); exit(); } - } /** @@ -653,10 +651,10 @@ class WC_Form_Handler { wc_add_notice( $removed_notice, apply_filters( 'woocommerce_cart_item_removed_notice_type', 'success' ) ); } - $referer = wp_get_referer() ? remove_query_arg( array( 'remove_item', 'add-to-cart', 'added-to-cart', 'order_again', '_wpnonce' ), add_query_arg( 'removed_item', '1', wp_get_referer() ) ) : wc_get_cart_url(); - wp_safe_redirect( $referer ); - exit; - + if ( wp_get_referer() ) { + wp_safe_redirect( remove_query_arg( array( 'remove_item', 'add-to-cart', 'added-to-cart', 'order_again', '_wpnonce' ), add_query_arg( 'removed_item', '1', wp_get_referer() ) ) ); + exit; + } } elseif ( ! empty( $_GET['undo_item'] ) && isset( $_GET['_wpnonce'] ) && wp_verify_nonce( $nonce_value, 'woocommerce-cart' ) ) { // Undo Cart Item. @@ -664,10 +662,10 @@ class WC_Form_Handler { WC()->cart->restore_cart_item( $cart_item_key ); - $referer = wp_get_referer() ? remove_query_arg( array( 'undo_item', '_wpnonce' ), wp_get_referer() ) : wc_get_cart_url(); - wp_safe_redirect( $referer ); - exit; - + if ( wp_get_referer() ) { + wp_safe_redirect( remove_query_arg( array( 'undo_item', '_wpnonce' ), wp_get_referer() ) ); + exit; + } } // Update Cart - checks apply_coupon too because they are in the same form. @@ -722,9 +720,11 @@ class WC_Form_Handler { exit; } elseif ( $cart_updated ) { wc_add_notice( __( 'Cart updated.', 'woocommerce' ), apply_filters( 'woocommerce_cart_updated_notice_type', 'success' ) ); - $referer = remove_query_arg( array( 'remove_coupon', 'add-to-cart' ), ( wp_get_referer() ? wp_get_referer() : wc_get_cart_url() ) ); - wp_safe_redirect( $referer ); - exit; + + if ( wp_get_referer() ) { + wp_safe_redirect( remove_query_arg( array( 'remove_coupon', 'add-to-cart' ), wp_get_referer() ) ); + exit; + } } } } @@ -986,7 +986,7 @@ class WC_Form_Handler { } } - // Peform the login. + // Perform the login. $user = wp_signon( apply_filters( 'woocommerce_login_credentials', $creds ), is_ssl() ); if ( is_wp_error( $user ) ) { diff --git a/plugins/woocommerce/includes/class-wc-frontend-scripts.php b/plugins/woocommerce/includes/class-wc-frontend-scripts.php index c52309655aa..b77615b8451 100644 --- a/plugins/woocommerce/includes/class-wc-frontend-scripts.php +++ b/plugins/woocommerce/includes/class-wc-frontend-scripts.php @@ -234,9 +234,9 @@ class WC_Frontend_Scripts { 'version' => $version, ), 'select2' => array( - 'src' => self::get_asset_url( 'assets/js/selectWoo/selectWoo.full' . $suffix . '.js' ), + 'src' => self::get_asset_url( 'assets/js/select2/select2.full' . $suffix . '.js' ), 'deps' => array( 'jquery' ), - 'version' => '1.0.9-wc.' . $version, + 'version' => '4.0.3-wc.' . $version, ), 'selectWoo' => array( 'src' => self::get_asset_url( 'assets/js/selectWoo/selectWoo.full' . $suffix . '.js' ), diff --git a/plugins/woocommerce/includes/class-wc-geo-ip.php b/plugins/woocommerce/includes/class-wc-geo-ip.php index 392025a83f7..9c5e751d270 100644 --- a/plugins/woocommerce/includes/class-wc-geo-ip.php +++ b/plugins/woocommerce/includes/class-wc-geo-ip.php @@ -633,7 +633,7 @@ class WC_Geo_IP { ); /** - * Contry names. + * Country names. * * @var array */ diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php index 2b1be80cda7..e6d591d8d11 100644 --- a/plugins/woocommerce/includes/class-wc-install.php +++ b/plugins/woocommerce/includes/class-wc-install.php @@ -17,7 +17,7 @@ use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchro use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil; use Automattic\WooCommerce\Internal\WCCom\ConnectionHelper as WCConnectionHelper; use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; -use Automattic\WooCommerce\Utilities\OrderUtil; +use Automattic\WooCommerce\Utilities\{ OrderUtil, PluginUtil }; use Automattic\WooCommerce\Internal\Utilities\PluginInstaller; defined( 'ABSPATH' ) || exit; @@ -259,6 +259,13 @@ class WC_Install { 'wc_update_910_add_launch_your_store_tour_option', 'wc_update_910_remove_obsolete_user_meta', ), + '9.2.0' => array( + 'wc_update_920_add_wc_hooked_blocks_version_option', + ), + '9.3.0' => array( + 'wc_update_930_add_woocommerce_coming_soon_option', + 'wc_update_930_migrate_user_meta_for_launch_your_store_tour', + ), ); /** @@ -289,6 +296,7 @@ class WC_Install { add_action( 'init', array( __CLASS__, 'check_version' ), 5 ); add_action( 'init', array( __CLASS__, 'manual_database_update' ), 20 ); add_action( 'woocommerce_newly_installed', array( __CLASS__, 'maybe_enable_hpos' ), 20 ); + add_action( 'woocommerce_newly_installed', array( __CLASS__, 'add_coming_soon_option' ), 20 ); add_action( 'admin_init', array( __CLASS__, 'wc_admin_db_update_notice' ) ); add_action( 'admin_init', array( __CLASS__, 'add_admin_note_after_page_created' ) ); add_action( 'woocommerce_run_update_callback', array( __CLASS__, 'run_update_callback' ) ); @@ -983,6 +991,17 @@ class WC_Install { } } + /** + * Add the woocommerce_coming_soon option for new shops. + * + * Ensure that the option is set for all shops, even if core profiler is disabled on the host. + * + * @since 9.3.0 + */ + public static function add_coming_soon_option() { + add_option( 'woocommerce_coming_soon', 'no' ); + } + /** * Checks whether HPOS should be enabled for new shops. * @@ -1264,7 +1283,8 @@ class WC_Install { return; } - if ( in_array( $legacy_api_plugin, wp_get_active_and_valid_plugins(), true ) ) { + $active_valid_plugins = wc_get_container()->get( PluginUtil::class )->get_all_active_valid_plugins(); + if ( in_array( $legacy_api_plugin, $active_valid_plugins, true ) ) { return; } diff --git a/plugins/woocommerce/includes/class-wc-post-data.php b/plugins/woocommerce/includes/class-wc-post-data.php index c1e3aa6b5f0..56e826b34c5 100644 --- a/plugins/woocommerce/includes/class-wc-post-data.php +++ b/plugins/woocommerce/includes/class-wc-post-data.php @@ -58,6 +58,8 @@ class WC_Post_Data { // Meta cache flushing. add_action( 'updated_post_meta', array( __CLASS__, 'flush_object_meta_cache' ), 10, 4 ); + add_action( 'added_post_meta', array( __CLASS__, 'flush_object_meta_cache' ), 10, 4 ); + add_action( 'deleted_post_meta', array( __CLASS__, 'flush_object_meta_cache' ), 10, 4 ); add_action( 'updated_order_item_meta', array( __CLASS__, 'flush_object_meta_cache' ), 10, 4 ); } @@ -405,6 +407,7 @@ class WC_Post_Data { $data_store->untrash_variations( $id ); wc_product_force_unique_sku( $id ); + self::clear_global_unique_id_if_necessary( $id ); wc_get_container()->get( ProductAttributesLookupDataStore::class )->on_product_changed( $id ); } elseif ( 'product_variation' === $post_type ) { @@ -412,6 +415,19 @@ class WC_Post_Data { } } + /** + * Clear global unique id if it's not unique. + * + * @param mixed $id Post ID. + */ + private static function clear_global_unique_id_if_necessary( $id ) { + $product = wc_get_product( $id ); + if ( $product && ! wc_product_has_global_unique_id( $id, $product->get_global_unique_id() ) ) { + $product->set_global_unique_id( '' ); + $product->save(); + } + } + /** * Get the post type for a given post. * diff --git a/plugins/woocommerce/includes/class-wc-product-simple.php b/plugins/woocommerce/includes/class-wc-product-simple.php index e49f4959d0f..98bb00f3ffe 100644 --- a/plugins/woocommerce/includes/class-wc-product-simple.php +++ b/plugins/woocommerce/includes/class-wc-product-simple.php @@ -74,4 +74,28 @@ class WC_Product_Simple extends WC_Product { return apply_filters( 'woocommerce_product_add_to_cart_description', sprintf( $text, $this->get_name() ), $this ); } + + /** + * Get the add to cart button success message - used to update the mini cart live region. + * + * @return string + */ + public function add_to_cart_success_message() { + $text = ''; + + if ( $this->is_purchasable() && $this->is_in_stock() ) { + /* translators: %s: Product title */ + $text = __( '“%s” has been added to your cart', 'woocommerce' ); + $text = sprintf( $text, $this->get_name() ); + } + + /** + * Filter product add to cart success message. + * + * @since 9.2.0 + * @param string $text The success message when a product is added to the cart. + * @param WC_Product_Simple $this Reference to the current WC_Product_Simple instance. + */ + return apply_filters( 'woocommerce_product_add_to_cart_success_message', $text, $this ); + } } diff --git a/plugins/woocommerce/includes/class-wc-structured-data.php b/plugins/woocommerce/includes/class-wc-structured-data.php index 213fb9788f3..98acb513224 100644 --- a/plugins/woocommerce/includes/class-wc-structured-data.php +++ b/plugins/woocommerce/includes/class-wc-structured-data.php @@ -214,6 +214,12 @@ class WC_Structured_Data { $markup['sku'] = $product->get_id(); } + // Add GTIN only if it's a valid number. + $gtin = $product->get_global_unique_id(); + if ( $gtin && is_numeric( $gtin ) ) { + $markup['gtin'] = $gtin; + } + if ( '' !== $product->get_price() ) { // Assume prices will be valid until the end of next year, unless on sale and there is an end date. $price_valid_until = gmdate( 'Y-12-31', time() + YEAR_IN_SECONDS ); diff --git a/plugins/woocommerce/includes/class-wc-tracker.php b/plugins/woocommerce/includes/class-wc-tracker.php index f41f30fbab1..fc535ac1cd3 100644 --- a/plugins/woocommerce/includes/class-wc-tracker.php +++ b/plugins/woocommerce/includes/class-wc-tracker.php @@ -112,13 +112,20 @@ class WC_Tracker { * However, there are version of JP where \Automattic\Jetpack\Status exists, but does *not* contain is_staging_site method, * so with those, code still needs to use the previous check as a fallback. * + * After upgrading Jetpack Status to v3.3.2 is_staging_site is also deprecated and in_safe_mode is the new replacement. + * So we check this first of all. + * * @return bool */ private static function is_jetpack_staging_site() { if ( class_exists( '\Automattic\Jetpack\Status' ) ) { - // Preferred way of checking with Jetpack 8.1+. + $jp_status = new \Automattic\Jetpack\Status(); - if ( is_callable( array( $jp_status, 'is_staging_site' ) ) ) { + + if ( is_callable( array( $jp_status, 'in_safe_mode' ) ) ) { + return $jp_status->in_safe_mode(); + } elseif ( is_callable( array( $jp_status, 'is_staging_site' ) ) ) { + // Preferred way of checking with Jetpack 8.1+. return $jp_status->is_staging_site(); } } @@ -965,6 +972,7 @@ class WC_Tracker { 'hpos_transactions_enabled' => get_option( 'woocommerce_use_db_transactions_for_custom_orders_table_data_sync' ), 'hpos_transactions_level' => get_option( 'woocommerce_db_transactions_isolation_level_for_custom_orders_table_data_sync' ), 'show_marketplace_suggestions' => get_option( 'woocommerce_show_marketplace_suggestions' ), + 'admin_install_timestamp' => get_option( 'woocommerce_admin_install_timestamp' ), ); } diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php index 9eb3f3c003d..41104fcbd65 100644 --- a/plugins/woocommerce/includes/class-woocommerce.php +++ b/plugins/woocommerce/includes/class-woocommerce.php @@ -10,6 +10,7 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Internal\AssignDefaultCategory; use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController; +use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonAdminBarBadge; use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonCacheInvalidator; use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonRequestHandler; use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController; @@ -26,10 +27,10 @@ use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; use Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub; use Automattic\WooCommerce\Internal\Utilities\WebhookUtil; use Automattic\WooCommerce\Internal\Admin\Marketplace; +use Automattic\WooCommerce\Internal\McStats; use Automattic\WooCommerce\Proxies\LegacyProxy; use Automattic\WooCommerce\Utilities\{LoggingUtil, RestApiUtil, TimeUtil}; -use Automattic\WooCommerce\Admin\WCAdminHelper; -use Automattic\WooCommerce\Admin\Features\Features; +use Automattic\WooCommerce\Internal\Logging\RemoteLogger; /** * Main WooCommerce Class. @@ -45,7 +46,7 @@ final class WooCommerce { * * @var string */ - public $version = '9.2.0'; + public $version = '9.4.0'; /** * WooCommerce Schema version. @@ -54,7 +55,7 @@ final class WooCommerce { * * @var string */ - public $db_version = '430'; + public $db_version = '920'; /** * The single instance of the class. @@ -307,8 +308,11 @@ final class WooCommerce { self::add_action( 'rest_api_init', array( $this, 'register_wp_admin_settings' ) ); add_action( 'woocommerce_installed', array( $this, 'add_woocommerce_remote_variant' ) ); add_action( 'woocommerce_updated', array( $this, 'add_woocommerce_remote_variant' ) ); + add_action( 'woocommerce_newly_installed', 'wc_set_hooked_blocks_version', 10 ); + self::add_filter( 'robots_txt', array( $this, 'robots_txt' ) ); add_filter( 'wp_plugin_dependencies_slug', array( $this, 'convert_woocommerce_slug' ) ); + self::add_filter( 'woocommerce_register_log_handlers', array( $this, 'register_remote_log_handler' ) ); // These classes set up hooks on instantiation. $container = wc_get_container(); @@ -326,6 +330,7 @@ final class WooCommerce { $container->get( WebhookUtil::class ); $container->get( Marketplace::class ); $container->get( TimeUtil::class ); + $container->get( ComingSoonAdminBarBadge::class ); $container->get( ComingSoonCacheInvalidator::class ); $container->get( ComingSoonRequestHandler::class ); @@ -402,6 +407,12 @@ final class WooCommerce { $context ); + // Record fatal error stats. + $container = wc_get_container(); + $mc_stats = $container->get( McStats::class ); + $mc_stats->add( 'error', 'fatal-errors-during-shutdown' ); + $mc_stats->do_server_side_stats(); + /** * Action triggered when there are errors during shutdown. * @@ -415,8 +426,6 @@ final class WooCommerce { * Define WC Constants. */ private function define_constants() { - $upload_dir = wp_upload_dir( null, false ); - $this->define( 'WC_ABSPATH', dirname( WC_PLUGIN_FILE ) . '/' ); $this->define( 'WC_PLUGIN_BASENAME', plugin_basename( WC_PLUGIN_FILE ) ); $this->define( 'WC_VERSION', $this->version ); @@ -435,8 +444,9 @@ final class WooCommerce { */ if ( defined( 'WC_LOG_DIR' ) ) { $this->define( 'WC_LOG_DIR_CUSTOM', true ); + } else { + $this->define( 'WC_LOG_DIR', LoggingUtil::get_log_directory( false ) ); } - $this->define( 'WC_LOG_DIR', LoggingUtil::get_log_directory() ); // These three are kept defined for compatibility, but are no longer used. $this->define( 'WC_NOTICE_MIN_PHP_VERSION', '7.2' ); @@ -702,6 +712,11 @@ final class WooCommerce { */ include_once WC_ABSPATH . 'includes/wccom-site/class-wc-wccom-site.php'; + /** + * Product Usage + */ + include_once WC_ABSPATH . 'includes/product-usage/class-wc-product-usage.php'; + /** * Libraries and packages. */ @@ -1038,6 +1053,43 @@ final class WooCommerce { } } + /** + * Tell bots not to index some WooCommerce-created directories. + * + * We try to detect the default "User-agent: *" added by WordPress and add our rules to that group, because + * it's possible that some bots will only interpret the first group of rules if there are multiple groups with + * the same user agent. + * + * @param string $output The contents that WordPress will output in a robots.txt file. + * + * @return string + */ + private function robots_txt( $output ) { + $path = ( ! empty( $site_url['path'] ) ) ? $site_url['path'] : ''; + + $lines = preg_split( '/\r\n|\r|\n/', $output ); + $agent_index = array_search( 'User-agent: *', $lines, true ); + + if ( false !== $agent_index ) { + $above = array_slice( $lines, 0, $agent_index + 1 ); + $below = array_slice( $lines, $agent_index + 1 ); + } else { + $above = $lines; + $below = array(); + + $above[] = ''; + $above[] = 'User-agent: *'; + } + + $above[] = "Disallow: $path/wp-content/uploads/wc-logs/"; + $above[] = "Disallow: $path/wp-content/uploads/woocommerce_transient_files/"; + $above[] = "Disallow: $path/wp-content/uploads/woocommerce_uploads/"; + + $lines = array_merge( $above, $below ); + + return implode( PHP_EOL, $lines ); + } + /** * Set tablenames inside WPDB object. */ @@ -1267,4 +1319,16 @@ final class WooCommerce { } return $slug; } + + /** + * Register the remote log handler. + * + * @param \WC_Log_Handler[] $handlers The handlers to register. + * + * @return \WC_Log_Handler[] + */ + private function register_remote_log_handler( $handlers ) { + $handlers[] = wc_get_container()->get( RemoteLogger::class ); + return $handlers; + } } diff --git a/plugins/woocommerce/includes/cli/class-wc-cli-com-command.php b/plugins/woocommerce/includes/cli/class-wc-cli-com-command.php index 5f6f7b63144..d56ec1b29fe 100644 --- a/plugins/woocommerce/includes/cli/class-wc-cli-com-command.php +++ b/plugins/woocommerce/includes/cli/class-wc-cli-com-command.php @@ -137,7 +137,7 @@ class WC_CLI_COM_Command { * # force connecting to WCCOM even if site is already connected. * $ wp wc com connect --force * - * # Pass password to comman. + * # Pass password to command. * $ wp wc com connect --password=PASSWORD * * @param array $args Positional arguments to include when calling the command. diff --git a/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php index 7882f1673aa..9a96390266e 100644 --- a/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php +++ b/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php @@ -5,6 +5,8 @@ * @package WooCommerce\Classes */ +use Automattic\WooCommerce\Utilities\OrderUtil; + if ( ! defined( 'ABSPATH' ) ) { exit; } @@ -189,22 +191,37 @@ class WC_Order_Data_Store_CPT extends Abstract_WC_Order_Data_Store_CPT implement // Also grab the current status so we can compare. $previous_status = get_post_status( $order->get_id() ); + // If the order doesn't exist in the DB, we will consider it as new. + if ( ! $previous_status && $order->get_id() === 0 ) { + $previous_status = 'new'; + } // Update the order. parent::update( $order ); - // Fire a hook depending on the status - this should be considered a creation if it was previously draft status. - $new_status = $order->get_status( 'edit' ); + $current_status = $order->get_status( 'edit' ); - if ( $new_status !== $previous_status && in_array( $previous_status, array( 'new', 'auto-draft', 'draft', 'checkout-draft' ), true ) ) { - do_action( 'woocommerce_new_order', $order->get_id(), $order ); - } else { - do_action( 'woocommerce_update_order', $order->get_id(), $order ); + // We need to remove the wc- prefix from the status for comparison and proper evaluation of new vs updated orders. + $previous_status = OrderUtil::remove_status_prefix( $previous_status ); + $current_status = OrderUtil::remove_status_prefix( $current_status ); + + $draft_statuses = array( 'new', 'auto-draft', 'draft', 'checkout-draft' ); + + // This hook should be fired only if the new status is not one of draft statuses and the previous status was one of the draft statuses. + if ( + $current_status !== $previous_status + && ! in_array( $current_status, $draft_statuses, true ) + && in_array( $previous_status, $draft_statuses, true ) + ) { + do_action( 'woocommerce_new_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + return; } + + do_action( 'woocommerce_update_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment } /** - * Helper method that updates all the post meta for an order based on it's settings in the WC_Order class. + * Helper method that updates all the post meta for an order based on its settings in the WC_Order class. * * @param WC_Order $order Order object. * @since 3.0.0 @@ -1003,6 +1020,40 @@ class WC_Order_Data_Store_CPT extends Abstract_WC_Order_Data_Store_CPT implement * @return array|object */ public function query( $query_vars ) { + /** + * Allows 3rd parties to filter query args that will trigger an unsupported notice. + * + * @since 9.2.0 + * + * @param array $unsupported_args Array of query arg names. + */ + $unsupported_args = (array) apply_filters( + 'woocommerce_order_data_store_cpt_query_unsupported_args', + array( 'meta_query', 'field_query' ) + ); + + // Trigger doing_it_wrong() for query vars only supported in HPOS. + $unsupported_args_in_query = array_keys( array_filter( array_intersect_key( $query_vars, array_flip( $unsupported_args ) ) ) ); + + if ( $unsupported_args_in_query && __CLASS__ === get_class( $this ) ) { + wc_doing_it_wrong( + __METHOD__, + esc_html( + sprintf( + // translators: %s is a comma separated list of query arguments. + _n( + 'Order query argument (%s) is not supported on the current order datastore.', + 'Order query arguments (%s) are not supported on the current order datastore.', + count( $unsupported_args_in_query ), + 'woocommerce' + ), + implode( ', ', $unsupported_args_in_query ) + ) + ), + '9.2.0' + ); + } + $args = $this->get_wp_query_args( $query_vars ); if ( ! empty( $args['errors'] ) ) { diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php index 7bdc8bd3368..b5a611d6f8a 100644 --- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php +++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php @@ -131,6 +131,20 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da $sku, $sku ); + + /** + * Filter to bail early on the SKU lock query. + * + * @since 9.3.0 + * + * @param bool|null $locked Set to a boolean value to short-circuit the SKU lock query. + * @param WC_Product $product The product being created. + */ + $locked = apply_filters( 'wc_product_pre_lock_on_sku', null, $product ); + if ( ! is_null( $locked ) ) { + return boolval( $locked ); + } + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $result = $wpdb->query( $query ); @@ -334,7 +348,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da $product->apply_changes(); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment - do_action( 'woocommerce_update_product', $product->get_id(), $product, $changes ); + do_action( 'woocommerce_update_product', $product->get_id(), $product ); } /** @@ -642,21 +656,21 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da // Fire actions to let 3rd parties know the stock is about to be changed. if ( $product->is_type( 'variation' ) ) { /** - * Action to signal that the value of 'stock_quantity' for a variation is about to change. - * - * @since 4.9 - * - * @param int $product The variation whose stock is about to change. - */ + * Action to signal that the value of 'stock_quantity' for a variation is about to change. + * + * @param WC_Product $product The variation whose stock is about to change. + * + * @since 4.9 + */ do_action( 'woocommerce_variation_before_set_stock', $product ); } else { /** - * Action to signal that the value of 'stock_quantity' for a product is about to change. - * - * @since 4.9 - * - * @param int $product The product whose stock is about to change. - */ + * Action to signal that the value of 'stock_quantity' for a product is about to change. + * + * @param WC_Product $product The product whose stock is about to change. + * + * @since 4.9 + */ do_action( 'woocommerce_product_before_set_stock', $product ); } break; @@ -732,16 +746,48 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da if ( in_array( 'stock_quantity', $this->updated_props, true ) ) { if ( $product->is_type( 'variation' ) ) { + /** + * Action to signal that the value of 'stock_quantity' for a variation has changed. + * + * @since 3.0 + * + * @param WC_Product $product The variation whose stock has changed. + */ do_action( 'woocommerce_variation_set_stock', $product ); } else { + /** + * Action to signal that the value of 'stock_quantity' for a product has changed. + * + * @since 3.0 + * + * @param WC_Product $product The variation whose stock has changed. + */ do_action( 'woocommerce_product_set_stock', $product ); } } if ( in_array( 'stock_status', $this->updated_props, true ) ) { if ( $product->is_type( 'variation' ) ) { + /** + * Action to signal that the `stock_status` for a variation has changed. + * + * @since 3.0 + * + * @param int $product_id The ID of the variation. + * @param string $stock_status The new stock status of the variation. + * @param WC_Product $product The product object. + */ do_action( 'woocommerce_variation_set_stock_status', $product->get_id(), $product->get_stock_status(), $product ); } else { + /** + * Action to signal that the `stock_status` for a product has changed. + * + * @since 3.0 + * + * @param int $product_id The ID of the product. + * @param string $stock_status The new stock status of the product. + * @param WC_Product $product The product object. + */ do_action( 'woocommerce_product_set_stock_status', $product->get_id(), $product->get_stock_status(), $product ); } } @@ -2237,23 +2283,26 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da $stock = 'yes' === $manage_stock ? wc_stock_amount( get_post_meta( $id, '_stock', true ) ) : null; $price = wc_format_decimal( get_post_meta( $id, '_price', true ) ); $sale_price = wc_format_decimal( get_post_meta( $id, '_sale_price', true ) ); - return array( - 'product_id' => absint( $id ), - 'sku' => get_post_meta( $id, '_sku', true ), - 'global_unique_id' => get_post_meta( $id, '_global_unique_id', true ), - 'virtual' => 'yes' === get_post_meta( $id, '_virtual', true ) ? 1 : 0, - 'downloadable' => 'yes' === get_post_meta( $id, '_downloadable', true ) ? 1 : 0, - 'min_price' => reset( $price_meta ), - 'max_price' => end( $price_meta ), - 'onsale' => $sale_price && $price === $sale_price ? 1 : 0, - 'stock_quantity' => $stock, - 'stock_status' => get_post_meta( $id, '_stock_status', true ), - 'rating_count' => array_sum( array_map( 'intval', (array) get_post_meta( $id, '_wc_rating_count', true ) ) ), - 'average_rating' => get_post_meta( $id, '_wc_average_rating', true ), - 'total_sales' => get_post_meta( $id, 'total_sales', true ), - 'tax_status' => get_post_meta( $id, '_tax_status', true ), - 'tax_class' => get_post_meta( $id, '_tax_class', true ), + $product_data = array( + 'product_id' => absint( $id ), + 'sku' => get_post_meta( $id, '_sku', true ), + 'virtual' => 'yes' === get_post_meta( $id, '_virtual', true ) ? 1 : 0, + 'downloadable' => 'yes' === get_post_meta( $id, '_downloadable', true ) ? 1 : 0, + 'min_price' => reset( $price_meta ), + 'max_price' => end( $price_meta ), + 'onsale' => $sale_price && $price === $sale_price ? 1 : 0, + 'stock_quantity' => $stock, + 'stock_status' => get_post_meta( $id, '_stock_status', true ), + 'rating_count' => array_sum( array_map( 'intval', (array) get_post_meta( $id, '_wc_rating_count', true ) ) ), + 'average_rating' => get_post_meta( $id, '_wc_average_rating', true ), + 'total_sales' => get_post_meta( $id, 'total_sales', true ), + 'tax_status' => get_post_meta( $id, '_tax_status', true ), + 'tax_class' => get_post_meta( $id, '_tax_class', true ), ); + if ( get_option( 'woocommerce_schema_version', 0 ) >= 920 ) { + $product_data['global_unique_id'] = get_post_meta( $id, '_global_unique_id', true ); + } + return $product_data; } return array(); } diff --git a/plugins/woocommerce/includes/emails/class-wc-email-new-order.php b/plugins/woocommerce/includes/emails/class-wc-email-new-order.php index 3451c64b3a3..33a49ebce34 100644 --- a/plugins/woocommerce/includes/emails/class-wc-email-new-order.php +++ b/plugins/woocommerce/includes/emails/class-wc-email-new-order.php @@ -109,10 +109,11 @@ if ( ! class_exists( 'WC_Email_New_Order' ) ) : } if ( $this->is_enabled() && $this->get_recipient() ) { - $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); - - $order->update_meta_data( '_new_order_email_sent', 'true' ); - $order->save(); + $email_sent_successfully = $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); + if ( $email_sent_successfully ) { + $order->update_meta_data( '_new_order_email_sent', 'true' ); + $order->save(); + } } $this->restore_locale(); diff --git a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php index 1c0b976dddf..10808c09bc4 100644 --- a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php +++ b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php @@ -262,7 +262,7 @@ class WC_Product_CSV_Exporter extends WC_CSV_Batch_Exporter { * @since 3.1.0 * * @param array $row An associative array with the data of a single row in the CSV file. - * @param WC_Product $product The product object correspnding to the current row. + * @param WC_Product $product The product object corresponding to the current row. * @param WC_Product_CSV_Exporter $exporter The instance of the CSV exporter. */ return apply_filters( 'woocommerce_product_export_row_data', $row, $product, $this ); diff --git a/plugins/woocommerce/includes/gateways/paypal/includes/settings-paypal.php b/plugins/woocommerce/includes/gateways/paypal/includes/settings-paypal.php index 19b8291d15c..fe2f53fa216 100644 --- a/plugins/woocommerce/includes/gateways/paypal/includes/settings-paypal.php +++ b/plugins/woocommerce/includes/gateways/paypal/includes/settings-paypal.php @@ -5,6 +5,8 @@ * @package WooCommerce\Classes\Payment */ +use Automattic\WooCommerce\Utilities\LoggingUtil; + defined( 'ABSPATH' ) || exit; return array( @@ -55,7 +57,11 @@ return array( 'label' => __( 'Enable logging', 'woocommerce' ), 'default' => 'no', /* translators: %s: URL */ - 'description' => sprintf( __( 'Log PayPal events, such as IPN requests, inside %s Note: this may log personal information. We recommend using this for debugging purposes only and deleting the logs when finished.', 'woocommerce' ), '' . WC_Log_Handler_File::get_log_file_path( 'paypal' ) . '' ), + 'description' => sprintf( + // translators: %s is a placeholder for a URL. + __( 'Log PayPal events such as IPN requests and review them on the Logs screen. Note: this may log personal information. We recommend using this for debugging purposes only and deleting the logs when finished.', 'woocommerce' ), + esc_url( LoggingUtil::get_logs_tab_url() ) + ), ), 'ipn_notification' => array( 'title' => __( 'IPN email notifications', 'woocommerce' ), diff --git a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php index 27be278e6eb..804426ed9aa 100644 --- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php +++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php @@ -198,7 +198,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer { return absint( $original_id ); } - // See if the given ID maps to a valid product allready. + // See if the given ID maps to a valid product already. $existing_id = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' ) AND ID = %d;", $id ) ); // WPCS: db call ok, cache ok. if ( $existing_id ) { diff --git a/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php b/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php index e1e8150ac5e..ed9244ae56a 100644 --- a/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php +++ b/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php @@ -41,15 +41,6 @@ interface WC_Product_Data_Store_Interface { */ public function is_existing_sku( $product_id, $sku ); - /** - * Check if product unique ID is found for any other product IDs. - * - * @param int $product_id Product ID. - * @param string $global_unique_id Unique ID. - * @return bool - */ - public function is_existing_global_unique_id( $product_id, $global_unique_id ); - /** * Return product ID based on SKU. * @@ -58,13 +49,6 @@ interface WC_Product_Data_Store_Interface { */ public function get_product_id_by_sku( $sku ); - /** - * Return product ID based on Unique ID. - * - * @param string $global_unique_id Unique ID. - * @return int - */ - public function get_product_id_by_global_unique_id( $global_unique_id ); /** * Returns an array of IDs of products that have sales starting soon. diff --git a/plugins/woocommerce/includes/interfaces/class-wc-queue-interface.php b/plugins/woocommerce/includes/interfaces/class-wc-queue-interface.php index 3cfe06845ce..c4e3ef442e7 100644 --- a/plugins/woocommerce/includes/interfaces/class-wc-queue-interface.php +++ b/plugins/woocommerce/includes/interfaces/class-wc-queue-interface.php @@ -91,7 +91,7 @@ interface WC_Queue_Interface { public function cancel_all( $hook, $args = array(), $group = '' ); /** - * Get the date and time for the next scheduled occurence of an action with a given hook + * Get the date and time for the next scheduled occurrence of an action with a given hook * (an optionally that matches certain args and group), if any. * * @param string $hook The hook that the job will trigger. diff --git a/plugins/woocommerce/includes/product-usage/class-wc-product-usage-rule-set.php b/plugins/woocommerce/includes/product-usage/class-wc-product-usage-rule-set.php new file mode 100644 index 00000000000..e8ccb1bc3a2 --- /dev/null +++ b/plugins/woocommerce/includes/product-usage/class-wc-product-usage-rule-set.php @@ -0,0 +1,50 @@ +rules = $rules; + } + + /** + * Retrieve the value of a rule by name + * + * @param string $rule_name name of the rule to retrieve value. + * @return mixed|null + */ + public function get_rule( string $rule_name ) { + if ( ! isset( $this->rules[ $rule_name ] ) ) { + return null; + } + + return $this->rules[ $rule_name ]; + } +} diff --git a/plugins/woocommerce/includes/product-usage/class-wc-product-usage.php b/plugins/woocommerce/includes/product-usage/class-wc-product-usage.php new file mode 100644 index 00000000000..7e986309fc0 --- /dev/null +++ b/plugins/woocommerce/includes/product-usage/class-wc-product-usage.php @@ -0,0 +1,87 @@ + $product_id ) ); + if ( empty( $subscriptions ) ) { + return new WC_Product_Usage_Rule_Set( $rules ); + } + + // Product should only have a single connected subscription on current store. + $product_subscription = current( $subscriptions ); + if ( $product_subscription['expired'] ) { + return new WC_Product_Usage_Rule_Set( $rules ); + } + + return null; + } + + /** + * Get the product usage rule for a product. + * + * @param int $product_id product id to get feature restriction rules. + * @return array|null + * @since 9.3.0 + */ + private static function get_product_usage_restriction_rule( int $product_id ): ?array { + $rules = WC_Helper::get_product_usage_notice_rules(); + if ( empty( $rules['restricted_products'][ $product_id ] ) ) { + return null; + } + + return $rules['restricted_products'][ $product_id ]; + } +} + +WC_Product_Usage::load(); diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php index a8aa9c46ede..aeebf511d81 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php @@ -468,7 +468,7 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller { } // Format the order status. - $data['status'] = 'wc-' === substr( $data['status'], 0, 3 ) ? substr( $data['status'], 3 ) : $data['status']; + $data['status'] = OrderUtil::remove_status_prefix( $data['status'] ); // Format line items. foreach ( $format_line_items as $key ) { diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php index d9c8ad8d9fe..822b2a651f1 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php @@ -123,42 +123,42 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller { */ public function get_tools() { $tools = array( - 'clear_transients' => array( + 'clear_transients' => array( 'name' => __( 'WooCommerce transients', 'woocommerce' ), 'button' => __( 'Clear transients', 'woocommerce' ), 'desc' => __( 'This tool will clear the product/shop transients cache.', 'woocommerce' ), ), - 'clear_expired_transients' => array( + 'clear_expired_transients' => array( 'name' => __( 'Expired transients', 'woocommerce' ), 'button' => __( 'Clear transients', 'woocommerce' ), 'desc' => __( 'This tool will clear ALL expired transients from WordPress.', 'woocommerce' ), ), - 'delete_orphaned_variations' => array( + 'delete_orphaned_variations' => array( 'name' => __( 'Orphaned variations', 'woocommerce' ), 'button' => __( 'Delete orphaned variations', 'woocommerce' ), 'desc' => __( 'This tool will delete all variations which have no parent.', 'woocommerce' ), ), - 'clear_expired_download_permissions' => array( + 'clear_expired_download_permissions' => array( 'name' => __( 'Used-up download permissions', 'woocommerce' ), 'button' => __( 'Clean up download permissions', 'woocommerce' ), 'desc' => __( 'This tool will delete expired download permissions and permissions with 0 remaining downloads.', 'woocommerce' ), ), - 'regenerate_product_lookup_tables' => array( + 'regenerate_product_lookup_tables' => array( 'name' => __( 'Product lookup tables', 'woocommerce' ), 'button' => __( 'Regenerate', 'woocommerce' ), 'desc' => __( 'This tool will regenerate product lookup table data. This process may take a while.', 'woocommerce' ), ), - 'recount_terms' => array( + 'recount_terms' => array( 'name' => __( 'Term counts', 'woocommerce' ), 'button' => __( 'Recount terms', 'woocommerce' ), 'desc' => __( 'This tool will recount product terms - useful when changing your settings in a way which hides products from the catalog.', 'woocommerce' ), ), - 'reset_roles' => array( + 'reset_roles' => array( 'name' => __( 'Capabilities', 'woocommerce' ), 'button' => __( 'Reset capabilities', 'woocommerce' ), 'desc' => __( 'This tool will reset the admin, customer and shop_manager roles to default. Use this if your users cannot access all of the WooCommerce admin pages.', 'woocommerce' ), ), - 'clear_sessions' => array( + 'clear_sessions' => array( 'name' => __( 'Clear customer sessions', 'woocommerce' ), 'button' => __( 'Clear', 'woocommerce' ), 'desc' => sprintf( @@ -167,7 +167,7 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller { __( 'This tool will delete all customer session data from the database, including current carts and saved carts in the database.', 'woocommerce' ) ), ), - 'clear_template_cache' => array( + 'clear_template_cache' => array( 'name' => __( 'Clear template cache', 'woocommerce' ), 'button' => __( 'Clear', 'woocommerce' ), 'desc' => sprintf( @@ -176,7 +176,16 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller { __( 'This tool will empty the template cache.', 'woocommerce' ) ), ), - 'install_pages' => array( + 'clear_system_status_theme_info_cache' => array( + 'name' => __( 'Clear system status theme info cache', 'woocommerce' ), + 'button' => __( 'Clear', 'woocommerce' ), + 'desc' => sprintf( + '%1$s %2$s', + __( 'Note:', 'woocommerce' ), + __( 'This tool will empty the system status theme info cache.', 'woocommerce' ) + ), + ), + 'install_pages' => array( 'name' => __( 'Create default WooCommerce pages', 'woocommerce' ), 'button' => __( 'Create pages', 'woocommerce' ), 'desc' => sprintf( @@ -185,7 +194,7 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller { __( 'This tool will install all the missing WooCommerce pages. Pages already defined and set up will not be replaced.', 'woocommerce' ) ), ), - 'delete_taxes' => array( + 'delete_taxes' => array( 'name' => __( 'Delete WooCommerce tax rates', 'woocommerce' ), 'button' => __( 'Delete tax rates', 'woocommerce' ), 'desc' => sprintf( @@ -194,12 +203,12 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller { __( 'This option will delete ALL of your tax rates, use with caution. This action cannot be reversed.', 'woocommerce' ) ), ), - 'regenerate_thumbnails' => array( + 'regenerate_thumbnails' => array( 'name' => __( 'Regenerate shop thumbnails', 'woocommerce' ), 'button' => __( 'Regenerate', 'woocommerce' ), 'desc' => __( 'This will regenerate all shop thumbnails to match your theme and/or image settings.', 'woocommerce' ), ), - 'db_update_routine' => array( + 'db_update_routine' => array( 'name' => __( 'Update database', 'woocommerce' ), 'button' => __( 'Update database', 'woocommerce' ), 'desc' => sprintf( @@ -567,6 +576,11 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller { } break; + case 'clear_system_status_theme_info_cache': + wc_clear_system_status_theme_info_cache(); + $message = __( 'System status theme info cache cleared.', 'woocommerce' ); + break; + case 'verify_db_tables': if ( ! method_exists( 'WC_Install', 'verify_base_tables' ) ) { $message = __( 'You need WooCommerce 4.2 or newer to run this tool.', 'woocommerce' ); diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php index e9da6f31cdd..f8c244c7e68 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php @@ -13,7 +13,7 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Internal\WCCom\ConnectionHelper; use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories; use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer as Order_DataSynchronizer; -use Automattic\WooCommerce\Utilities\{ LoggingUtil, OrderUtil }; +use Automattic\WooCommerce\Utilities\{ LoggingUtil, OrderUtil, PluginUtil }; /** * System status controller class. @@ -373,7 +373,41 @@ class WC_REST_System_Status_V2_Controller extends WC_REST_Controller { 'context' => array( 'view' ), 'readonly' => true, 'items' => array( - 'type' => 'string', + 'type' => 'object', + 'properties' => array( + 'plugin' => array( + 'description' => __( 'Plugin basename. The path to the main plugin file relative to the plugins directory.', 'woocommerce' ), + 'type' => 'string', + ), + 'name' => array( + 'description' => __( 'Name of the plugin.', 'woocommerce' ), + 'type' => 'string', + ), + 'version' => array( + 'description' => __( 'Current plugin version.', 'woocommerce' ), + 'type' => 'string', + ), + 'version_latest' => array( + 'description' => __( 'Latest available plugin version.', 'woocommerce' ), + 'type' => 'string', + ), + 'url' => array( + 'description' => __( 'Plugin URL.', 'woocommerce' ), + 'type' => 'string', + ), + 'author_name' => array( + 'description' => __( 'Plugin author name.', 'woocommerce' ), + 'type' => 'string', + ), + 'author_url' => array( + 'description' => __( 'Plugin author URL.', 'woocommerce' ), + 'type' => 'string', + ), + 'network_activated' => array( + 'description' => __( 'Whether the plugin can only be activated network-wide.', 'woocommerce' ), + 'type' => 'boolean', + ), + ), ), ), 'inactive_plugins' => array( @@ -382,7 +416,41 @@ class WC_REST_System_Status_V2_Controller extends WC_REST_Controller { 'context' => array( 'view' ), 'readonly' => true, 'items' => array( - 'type' => 'string', + 'type' => 'object', + 'properties' => array( + 'plugin' => array( + 'description' => __( 'Plugin basename. The path to the main plugin file relative to the plugins directory.', 'woocommerce' ), + 'type' => 'string', + ), + 'name' => array( + 'description' => __( 'Name of the plugin.', 'woocommerce' ), + 'type' => 'string', + ), + 'version' => array( + 'description' => __( 'Current plugin version.', 'woocommerce' ), + 'type' => 'string', + ), + 'version_latest' => array( + 'description' => __( 'Latest available plugin version.', 'woocommerce' ), + 'type' => 'string', + ), + 'url' => array( + 'description' => __( 'Plugin URL.', 'woocommerce' ), + 'type' => 'string', + ), + 'author_name' => array( + 'description' => __( 'Plugin author name.', 'woocommerce' ), + 'type' => 'string', + ), + 'author_url' => array( + 'description' => __( 'Plugin author URL.', 'woocommerce' ), + 'type' => 'string', + ), + 'network_activated' => array( + 'description' => __( 'Whether the plugin can only be activated network-wide.', 'woocommerce' ), + 'type' => 'boolean', + ), + ), ), ), 'dropins_mu_plugins' => array( @@ -864,7 +932,7 @@ class WC_REST_System_Status_V2_Controller extends WC_REST_Controller { } $database_version = wc_get_server_database_version(); - $log_directory = LoggingUtil::get_log_directory(); + $log_directory = LoggingUtil::get_log_directory( false ); // Return all environment info. Described by JSON Schema. return array( @@ -1044,15 +1112,10 @@ class WC_REST_System_Status_V2_Controller extends WC_REST_Controller { return array(); } - $active_plugins = (array) get_option( 'active_plugins', array() ); - if ( is_multisite() ) { - $network_activated_plugins = array_keys( get_site_option( 'active_sitewide_plugins', array() ) ); - $active_plugins = array_merge( $active_plugins, $network_activated_plugins ); - } + $active_valid_plugins = wc_get_container()->get( PluginUtil::class )->get_all_active_valid_plugins(); + $active_plugins_data = array(); - $active_plugins_data = array(); - - foreach ( $active_plugins as $plugin ) { + foreach ( $active_valid_plugins as $plugin ) { $data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin ); $active_plugins_data[] = $this->format_plugin_data( $plugin, $data ); } diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php index 5e249c55ba7..53ed28e80fe 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php @@ -131,7 +131,7 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller { foreach ( $value as $item ) { if ( is_array( $item ) ) { if ( $this->item_is_null( $item ) || ( isset( $item['quantity'] ) && 0 === $item['quantity'] ) ) { - $order->remove_item( $item['id'] ); + $this->remove_item( $order, $key, $item['id'] ); } else { $this->set_item( $order, $key, $item ); } @@ -170,6 +170,46 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller { return apply_filters( "woocommerce_rest_pre_insert_{$this->post_type}_object", $order, $request, $creating ); } + /** + * Wrapper method to remove order items. + * When updating, the item ID provided is checked to ensure it is associated + * with the order. + * + * @param WC_Order $order The order to remove the item from. + * @param string $item_type The item type (from the request, not from the item, e.g. 'line_items' rather than 'line_item'). + * @param int $item_id The ID of the item to remove. + * + * @return void + * @throws WC_REST_Exception If item ID is not associated with order. + */ + protected function remove_item( WC_Order $order, string $item_type, int $item_id ): void { + $item = $order->get_item( $item_id ); + + if ( ! $item ) { + throw new WC_REST_Exception( + 'woocommerce_rest_invalid_item_id', + esc_html__( 'Order item ID provided is not associated with order.', 'woocommerce' ), + 400 + ); + } + + if ( 'line_items' === $item_type ) { + require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php'; + wc_maybe_adjust_line_item_product_stock( $item, 0 ); + } + + /** + * Allow extensions be notified before the item is removed. + * + * @param WC_Order_Item $item The item object. + * + * @since 9.3.0. + */ + do_action( 'woocommerce_rest_remove_order_item', $item ); + + $order->remove_item( $item_id ); + } + /** * Save an object data. * diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php index 46fad08b36a..0e25996d107 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php @@ -472,6 +472,10 @@ class WC_REST_Product_Reviews_Controller extends WC_REST_Controller { update_comment_meta( $review_id, 'rating', ! empty( $request['rating'] ) ? $request['rating'] : '0' ); + if ( isset( $request['verified'] ) && ! empty( $request['verified'] ) ) { + update_comment_meta( $review_id, 'verified', $request['verified'] ); + } + $review = get_comment( $review_id ); /** @@ -586,6 +590,10 @@ class WC_REST_Product_Reviews_Controller extends WC_REST_Controller { update_comment_meta( $id, 'rating', $request['rating'] ); } + if ( isset( $request['verified'] ) && ! empty( $request['verified'] ) ) { + update_comment_meta( $id, 'verified', $request['verified'] ); + } + $review = get_comment( $id ); /** This action is documented in includes/api/class-wc-rest-product-reviews-controller.php */ @@ -1057,7 +1065,7 @@ class WC_REST_Product_Reviews_Controller extends WC_REST_Controller { } /** - * Get the reivew, if the ID is valid. + * Get the review, if the ID is valid. * * @since 3.5.0 * @param int $id Supplied ID. diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-shipping-classes-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-shipping-classes-controller.php index a8d37886478..f0baad08771 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-shipping-classes-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-shipping-classes-controller.php @@ -53,7 +53,7 @@ class WC_REST_Product_Shipping_Classes_Controller extends WC_REST_Product_Shippi } /** - * Callback fuction for the slug-suggestion endpoint. + * Callback function for the slug-suggestion endpoint. * * @param WP_REST_Request $request Full details about the request. * @return string The suggested slug. diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php index aa298f52749..aaa06648fe1 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php @@ -446,7 +446,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V if ( is_wp_error( $upload ) ) { /** - * Filter to check if it should supress the image upload error, false by default. + * Filter to check if it should suppress the image upload error, false by default. * * @since 4.5.0 * @param bool false If it should suppress. diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php index b4a3a933faa..f1c472a38cd 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php @@ -232,7 +232,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { } if ( wc_product_sku_enabled() ) { - // Do a partial match for a sku. Supercedes sku parameter that does exact matching. + // Do a partial match for a sku. Supersedes sku parameter that does exact matching. if ( ! empty( $request['search_sku'] ) ) { // Store this for use in the query clause filters. $this->search_sku_in_product_lookup_table = $request['search_sku']; diff --git a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php index 49dafb20c7e..c090b4a52a1 100644 --- a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php +++ b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php @@ -39,66 +39,55 @@ class WC_Shortcode_My_Account { return; } - if ( ! is_user_logged_in() || isset( $wp->query_vars['lost-password'] ) ) { + self::my_account_add_notices(); + + // Show the lost password page. This can still be accessed directly by logged in accounts which is important for the initial create password links sent via email. + if ( isset( $wp->query_vars['lost-password'] ) ) { + self::lost_password(); + return; + } + + // Show login form if not logged in. + if ( ! is_user_logged_in() ) { + wc_get_template( 'myaccount/form-login.php' ); + return; + } + + // Output the my account page. + self::my_account( $atts ); + } + + /** + * Add notices to the my account page. + * + * Historically a filter has existed to render a message above the my account page content while the user is + * logged out. See `woocommerce_my_account_message`. + */ + private static function my_account_add_notices() { + global $wp; + + if ( ! is_user_logged_in() ) { + /** + * Filters the message shown on the 'my account' page when the user is not logged in. + * + * @since 2.6.0 + */ $message = apply_filters( 'woocommerce_my_account_message', '' ); if ( ! empty( $message ) ) { wc_add_notice( $message ); } + } - // After password reset, add confirmation message. - if ( ! empty( $_GET['password-reset'] ) ) { // WPCS: input var ok, CSRF ok. - wc_add_notice( __( 'Your password has been reset successfully.', 'woocommerce' ) ); - } + // After password reset, add confirmation message. + if ( ! empty( $_GET['password-reset'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + wc_add_notice( __( 'Your password has been reset successfully.', 'woocommerce' ) ); + } - if ( isset( $wp->query_vars['lost-password'] ) ) { - self::lost_password(); - } else { - wc_get_template( 'myaccount/form-login.php' ); - } - } else { - // Start output buffer since the html may need discarding for BW compatibility. - ob_start(); - - if ( isset( $wp->query_vars['customer-logout'] ) ) { - /* translators: %s: logout url */ - wc_add_notice( sprintf( __( 'Are you sure you want to log out? Confirm and log out', 'woocommerce' ), wc_logout_url() ) ); - } - - // Collect notices before output. - $notices = wc_get_notices(); - - // Output the new account page. - self::my_account( $atts ); - - /** - * Deprecated my-account.php template handling. This code should be - * removed in a future release. - * - * If woocommerce_account_content did not run, this is an old template - * so we need to render the endpoint content again. - */ - if ( ! did_action( 'woocommerce_account_content' ) ) { - if ( ! empty( $wp->query_vars ) ) { - foreach ( $wp->query_vars as $key => $value ) { - if ( 'pagename' === $key ) { - continue; - } - if ( has_action( 'woocommerce_account_' . $key . '_endpoint' ) ) { - ob_clean(); // Clear previous buffer. - wc_set_notices( $notices ); - wc_print_notices(); - do_action( 'woocommerce_account_' . $key . '_endpoint', $value ); - break; - } - } - - wc_deprecated_function( 'Your theme version of my-account.php template', '2.6', 'the latest version, which supports multiple account pages and navigation, from WC 2.6.0' ); - } - } - - // Send output buffer. - ob_end_flush(); + // After logging out without a nonce, add confirmation message. + if ( isset( $wp->query_vars['customer-logout'] ) && is_user_logged_in() ) { + /* translators: %s: logout url */ + wc_add_notice( sprintf( __( 'Are you sure you want to log out? Confirm and log out', 'woocommerce' ), wc_logout_url() ) ); } } diff --git a/plugins/woocommerce/includes/tracks/events/class-wc-extensions-tracking.php b/plugins/woocommerce/includes/tracks/events/class-wc-extensions-tracking.php index cbd16c61744..897af313d68 100644 --- a/plugins/woocommerce/includes/tracks/events/class-wc-extensions-tracking.php +++ b/plugins/woocommerce/includes/tracks/events/class-wc-extensions-tracking.php @@ -34,7 +34,7 @@ class WC_Extensions_Tracking { 'section' => empty( $_REQUEST['section'] ) ? '_featured' : wc_clean( wp_unslash( $_REQUEST['section'] ) ), ); - $event = 'extensions_view'; + $event = 'extensions_view'; if ( 'helper' === $properties['section'] ) { $event = 'subscriptions_view'; } @@ -52,7 +52,7 @@ class WC_Extensions_Tracking { * Send a Tracks event when the Extensions page gets a bad response or no response * from the WCCOM extensions API. * - * @param string $error + * @param string $error Error message. */ public function track_extensions_page_connection_error( string $error = '' ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended @@ -89,7 +89,17 @@ class WC_Extensions_Tracking { * Send a Tracks even when a Helper connection process completed successfully. */ public function track_helper_connection_complete() { - WC_Tracks::record_event( 'extensions_subscriptions_connected' ); + $properties = array(); + + if ( ! empty( $_GET['utm_source'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $properties['utm_source'] = wc_clean( wp_unslash( $_GET['utm_source'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + + if ( ! empty( $_GET['utm_campaign'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $properties['utm_campaign'] = wc_clean( wp_unslash( $_GET['utm_campaign'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + + WC_Tracks::record_event( 'extensions_subscriptions_connected', $properties ); } /** diff --git a/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php b/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php index 0021c9afc50..092be4f81ee 100644 --- a/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php +++ b/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php @@ -121,7 +121,7 @@ class WC_Product_Collection_Block_Tracking { 'in_single_product' => $is_in_single_product ? 'yes' : 'no', 'in_template_part' => $is_in_template_part ? 'yes' : 'no', 'in_synced_pattern' => $is_in_synced_pattern ? 'yes' : 'no', - 'filters' => $this->get_query_filters_usage_data( $block ), + 'filters' => wp_json_encode( $this->get_query_filters_usage_data( $block ) ), ); } diff --git a/plugins/woocommerce/includes/tracks/events/class-wc-products-tracking.php b/plugins/woocommerce/includes/tracks/events/class-wc-products-tracking.php index 3056f5b07d4..91532cf2a9f 100644 --- a/plugins/woocommerce/includes/tracks/events/class-wc-products-tracking.php +++ b/plugins/woocommerce/includes/tracks/events/class-wc-products-tracking.php @@ -29,8 +29,7 @@ class WC_Products_Tracking { add_action( 'load-edit.php', array( $this, 'track_products_view' ), 10 ); add_action( 'load-edit-tags.php', array( $this, 'track_categories_and_tags_view' ), 10, 2 ); add_action( 'edit_post', array( $this, 'track_product_updated' ), 10, 2 ); - add_action( 'woocommerce_new_product', array( $this, 'track_product_published' ), 10, 3 ); - add_action( 'woocommerce_update_product', array( $this, 'track_product_published' ), 10, 3 ); + add_action( 'wp_after_insert_post', array( $this, 'track_product_published' ), 10, 4 ); add_action( 'created_product_cat', array( $this, 'track_product_category_created' ) ); add_action( 'edited_product_cat', array( $this, 'track_product_category_updated' ) ); add_action( 'add_meta_boxes_product', array( $this, 'track_product_updated_client_side' ), 10 ); @@ -304,21 +303,24 @@ class WC_Products_Tracking { /** * Send a Tracks event when a product is published. * - * @param int $product_id Product ID. - * @param WC_Product $product Product object. - * @param array $changes Product changes. + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @param bool $update Whether this is an existing post being updated. + * @param null|WP_Post $post_before Null for new posts, the WP_Post object prior + * to the update for updated posts. */ - public function track_product_published( $product_id, $product, $changes = null ) { + public function track_product_published( $post_id, $post, $update, $post_before ) { if ( - ! isset( $product ) || - 'product' !== $product->post_type || - 'publish' !== $product->get_status( 'edit' ) || - ( $changes && ! isset( $changes['status'] ) ) + 'product' !== $post->post_type || + 'publish' !== $post->post_status || + ( $post_before && 'publish' === $post_before->post_status ) ) { return; } - $product_type_options = self::get_product_type_options( $product_id ); + $product = wc_get_product( $post_id ); + + $product_type_options = self::get_product_type_options( $post_id ); $product_type_options_string = self::get_product_type_options_string( $product_type_options ); $properties = array( @@ -332,7 +334,7 @@ class WC_Products_Tracking { 'is_virtual' => $product->is_virtual() ? 'yes' : 'no', 'manage_stock' => $product->get_manage_stock() ? 'yes' : 'no', 'menu_order' => $product->get_menu_order() ? 'yes' : 'no', - 'product_id' => $product_id, + 'product_id' => $post_id, 'product_gallery' => count( $product->get_gallery_image_ids() ), 'product_image' => $product->get_image_id() ? 'yes' : 'no', 'product_type' => $product->get_type(), diff --git a/plugins/woocommerce/includes/wc-account-functions.php b/plugins/woocommerce/includes/wc-account-functions.php index 7b76c515dae..7a4fe452588 100644 --- a/plugins/woocommerce/includes/wc-account-functions.php +++ b/plugins/woocommerce/includes/wc-account-functions.php @@ -22,6 +22,11 @@ function wc_lostpassword_url( $default_url = '' ) { return $default_url; } + // Don't change the admin form. + if ( did_action( 'login_form_login' ) ) { + return $default_url; + } + // Don't redirect to the woocommerce endpoint on global network admin lost passwords. if ( is_multisite() && isset( $_GET['redirect_to'] ) && false !== strpos( wp_unslash( $_GET['redirect_to'] ), network_admin_url() ) ) { // WPCS: input var ok, sanitization ok, CSRF ok. return $default_url; @@ -131,21 +136,15 @@ function wc_get_account_menu_items() { } /** - * Get account menu item classes. + * Find current item in account menu. * - * @since 2.6.0 + * @since 9.3.0 * @param string $endpoint Endpoint. - * @return string + * @return bool */ -function wc_get_account_menu_item_classes( $endpoint ) { +function wc_is_current_account_menu_item( $endpoint ) { global $wp; - $classes = array( - 'woocommerce-MyAccount-navigation-link', - 'woocommerce-MyAccount-navigation-link--' . $endpoint, - ); - - // Set current item class. $current = isset( $wp->query_vars[ $endpoint ] ); if ( 'dashboard' === $endpoint && ( isset( $wp->query_vars['page'] ) || empty( $wp->query_vars ) ) ) { $current = true; // Dashboard is not an endpoint, so needs a custom check. @@ -155,7 +154,23 @@ function wc_get_account_menu_item_classes( $endpoint ) { $current = true; } - if ( $current ) { + return $current; +} + +/** + * Get account menu item classes. + * + * @since 2.6.0 + * @param string $endpoint Endpoint. + * @return string + */ +function wc_get_account_menu_item_classes( $endpoint ) { + $classes = array( + 'woocommerce-MyAccount-navigation-link', + 'woocommerce-MyAccount-navigation-link--' . $endpoint, + ); + + if ( wc_is_current_account_menu_item( $endpoint ) ) { $classes[] = 'is-active'; } @@ -176,11 +191,13 @@ function wc_get_account_endpoint_url( $endpoint ) { return wc_get_page_permalink( 'myaccount' ); } + $url = wc_get_endpoint_url( $endpoint, '', wc_get_page_permalink( 'myaccount' ) ); + if ( 'customer-logout' === $endpoint ) { - return wc_logout_url(); + return wp_nonce_url( $url, 'customer-logout' ); } - return wc_get_endpoint_url( $endpoint, '', wc_get_page_permalink( 'myaccount' ) ); + return $url; } /** diff --git a/plugins/woocommerce/includes/wc-cart-functions.php b/plugins/woocommerce/includes/wc-cart-functions.php index c4657188505..d0a048d3bbb 100644 --- a/plugins/woocommerce/includes/wc-cart-functions.php +++ b/plugins/woocommerce/includes/wc-cart-functions.php @@ -123,9 +123,9 @@ function wc_add_to_cart_message( $products, $show_qty = false, $return = false ) $wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : ''; if ( 'yes' === get_option( 'woocommerce_cart_redirect_after_add' ) ) { $return_to = apply_filters( 'woocommerce_continue_shopping_redirect', wc_get_raw_referer() ? wp_validate_redirect( wc_get_raw_referer(), false ) : wc_get_page_permalink( 'shop' ) ); - $message = sprintf( '%s %s', esc_url( $return_to ), esc_attr( $wp_button_class ), esc_html__( 'Continue shopping', 'woocommerce' ), esc_html( $added_text ) ); + $message = sprintf( '%s %s', esc_html( $added_text ), esc_url( $return_to ), esc_attr( $wp_button_class ), esc_html__( 'Continue shopping', 'woocommerce' ) ); } else { - $message = sprintf( '%s %s', esc_url( wc_get_cart_url() ), esc_attr( $wp_button_class ), esc_html__( 'View cart', 'woocommerce' ), esc_html( $added_text ) ); + $message = sprintf( '%s %s', esc_html( $added_text ), esc_url( wc_get_cart_url() ), esc_attr( $wp_button_class ), esc_html__( 'View cart', 'woocommerce' ) ); } if ( has_filter( 'wc_add_to_cart_message' ) ) { @@ -170,6 +170,10 @@ function wc_format_list_of_items( $items ) { function wc_clear_cart_after_payment() { global $wp; + $should_clear_cart_after_payment = false; + $after_payment = false; + + // If the order has been received, clear the cart. if ( ! empty( $wp->query_vars['order-received'] ) ) { $order_id = absint( $wp->query_vars['order-received'] ); @@ -179,21 +183,39 @@ function wc_clear_cart_after_payment() { $order = wc_get_order( $order_id ); if ( $order instanceof WC_Order && hash_equals( $order->get_order_key(), $order_key ) ) { - WC()->cart->empty_cart(); + $should_clear_cart_after_payment = true; + $after_payment = true; } } } - if ( WC()->session->order_awaiting_payment > 0 ) { + // If the order is awaiting payment, and we haven't already decided to clear the cart, check the order status. + if ( is_object( WC()->session ) && WC()->session->order_awaiting_payment > 0 && ! $should_clear_cart_after_payment ) { $order = wc_get_order( WC()->session->order_awaiting_payment ); if ( $order instanceof WC_Order && $order->get_id() > 0 ) { - // If the order has not failed, or is not pending, the order must have gone through. - if ( ! $order->has_status( array( 'failed', 'pending', 'cancelled' ) ) ) { - WC()->cart->empty_cart(); - } + // If the order status is neither pending, failed, nor cancelled, the order must have gone through. + $should_clear_cart_after_payment = ! $order->has_status( array( 'failed', 'pending', 'cancelled' ) ); + $after_payment = true; } } + + // If it doesn't look like a payment happened, bail early. + if ( ! $after_payment ) { + return; + } + + /** + * Determine whether the cart should be cleared after payment. + * + * @since 9.3.0 + * @param bool $should_clear_cart_after_payment Whether the cart should be cleared after payment. + */ + $should_clear_cart_after_payment = apply_filters( 'woocommerce_should_clear_cart_after_payment', $should_clear_cart_after_payment ); + + if ( $should_clear_cart_after_payment ) { + WC()->cart->empty_cart(); + } } add_action( 'template_redirect', 'wc_clear_cart_after_payment', 20 ); @@ -391,7 +413,12 @@ function wc_cart_round_discount( $value, $precision ) { */ function wc_get_chosen_shipping_method_ids() { $method_ids = array(); - $chosen_methods = WC()->session->get( 'chosen_shipping_methods', array() ); + $chosen_methods = array(); + + if ( is_callable( array( WC()->session, 'get' ) ) ) { + $chosen_methods = WC()->session->get( 'chosen_shipping_methods', array() ); + } + foreach ( $chosen_methods as $chosen_method ) { if ( ! is_string( $chosen_method ) ) { continue; @@ -399,6 +426,7 @@ function wc_get_chosen_shipping_method_ids() { $chosen_method = explode( ':', $chosen_method ); $method_ids[] = current( $chosen_method ); } + return $method_ids; } diff --git a/plugins/woocommerce/includes/wc-core-functions.php b/plugins/woocommerce/includes/wc-core-functions.php index 9099609a91c..4158fd900e0 100644 --- a/plugins/woocommerce/includes/wc-core-functions.php +++ b/plugins/woocommerce/includes/wc-core-functions.php @@ -456,6 +456,15 @@ function wc_clear_template_cache() { } } +/** + * Clear the system status theme info cache. + * + * @since 9.4.0 + */ +function wc_clear_system_status_theme_info_cache() { + delete_transient( 'wc_system_status_theme_info' ); +} + /** * Get Base Currency Code. * @@ -1469,12 +1478,27 @@ function wc_transaction_query( $type = 'start', $force = false ) { /** * Gets the url to the cart page. * - * @since 2.5.0 + * @since 2.5.0 + * @since 9.3.0 To support shortcodes on other pages besides the main cart page, this returns the current URL if it is the cart page. * * @return string Url to cart page */ function wc_get_cart_url() { - return apply_filters( 'woocommerce_get_cart_url', wc_get_page_permalink( 'cart' ) ); + if ( is_cart() && isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) { + $protocol = is_ssl() ? 'https' : 'http'; + $current_url = esc_url_raw( $protocol . '://' . wp_unslash( $_SERVER['HTTP_HOST'] ) . wp_unslash( $_SERVER['REQUEST_URI'] ) ); + $cart_url = remove_query_arg( array( 'remove_item', 'add-to-cart', 'added-to-cart', 'order_again', '_wpnonce' ), $current_url ); + } else { + $cart_url = wc_get_page_permalink( 'cart' ); + } + + /** + * Filter the cart URL. + * + * @since 2.5.0 + * @param string $cart_url Cart URL. + */ + return apply_filters( 'woocommerce_get_cart_url', $cart_url ); } /** @@ -1580,6 +1604,8 @@ function wc_help_tip( $tip, $allow_html = false ) { $sanitized_tip = esc_attr( $tip ); } + $aria_label = wp_strip_all_tags( $tip ); + /** * Filter the help tip. * @@ -1592,7 +1618,7 @@ function wc_help_tip( $tip, $allow_html = false ) { * * @return string */ - return apply_filters( 'wc_help_tip', '', $sanitized_tip, $tip, $allow_html ); + return apply_filters( 'wc_help_tip', '', $sanitized_tip, $tip, $allow_html ); } /** @@ -1612,7 +1638,7 @@ function wc_get_wildcard_postcodes( $postcode, $country = '' ) { $formatted_postcode . '*', ); - for ( $i = 0; $i < $length; $i ++ ) { + for ( $i = 0; $i < $length; $i++ ) { $postcodes[] = ( function_exists( 'mb_substr' ) ? mb_substr( $formatted_postcode, 0, ( $i + 1 ) * -1 ) : substr( $formatted_postcode, 0, ( $i + 1 ) * -1 ) ) . '*'; } @@ -1707,7 +1733,7 @@ function wc_get_shipping_method_count( $include_legacy = false, $enabled_only = foreach ( $methods as $method ) { if ( isset( $method->enabled ) && 'yes' === $method->enabled && ! $method->supports( 'shipping-zones' ) ) { - $method_count++; + ++$method_count; } } } @@ -1807,7 +1833,7 @@ function wc_uasort_comparison( $a, $b ) { } /** - * Sort values based on ascii, usefull for special chars in strings. + * Sort values based on ascii, useful for special chars in strings. * * @param string $a First value. * @param string $b Second value. @@ -2264,7 +2290,7 @@ function wc_get_var( &$var, $default = null ) { */ function wc_enable_wc_plugin_headers( $headers ) { if ( ! class_exists( 'WC_Plugin_Updates' ) ) { - include_once dirname( __FILE__ ) . '/admin/plugin-updates/class-wc-plugin-updates.php'; + include_once __DIR__ . '/admin/plugin-updates/class-wc-plugin-updates.php'; } // WC requires at least - allows developers to define which version of WooCommerce the plugin requires to run. @@ -2299,7 +2325,7 @@ function wc_prevent_dangerous_auto_updates( $should_update, $plugin ) { } if ( ! class_exists( 'WC_Plugin_Updates' ) ) { - include_once dirname( __FILE__ ) . '/admin/plugin-updates/class-wc-plugin-updates.php'; + include_once __DIR__ . '/admin/plugin-updates/class-wc-plugin-updates.php'; } $new_version = wc_clean( $plugin->new_version ); @@ -2498,7 +2524,7 @@ function wc_selected( $value, $options ) { * Retrieves the MySQL server version. Based on $wpdb. * * @since 3.4.1 - * @return array Vesion information. + * @return array Version information. */ function wc_get_server_database_version() { global $wpdb; diff --git a/plugins/woocommerce/includes/wc-order-functions.php b/plugins/woocommerce/includes/wc-order-functions.php index 5c137c4c522..972705ab078 100644 --- a/plugins/woocommerce/includes/wc-order-functions.php +++ b/plugins/woocommerce/includes/wc-order-functions.php @@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer; use Automattic\WooCommerce\Internal\Utilities\Users; +use Automattic\WooCommerce\Utilities\OrderUtil; use Automattic\WooCommerce\Utilities\StringUtil; defined( 'ABSPATH' ) || exit; @@ -147,9 +148,9 @@ function wc_get_is_pending_statuses() { */ function wc_get_order_status_name( $status ) { $statuses = wc_get_order_statuses(); - $status = 'wc-' === substr( $status, 0, 3 ) ? substr( $status, 3 ) : $status; - $status = isset( $statuses[ 'wc-' . $status ] ) ? $statuses[ 'wc-' . $status ] : $status; - return $status; + $status = OrderUtil::remove_status_prefix( $status ); + + return $statuses[ 'wc-' . $status ] ?? $status; } /** @@ -539,10 +540,11 @@ function wc_create_refund( $args = array() ) { throw new Exception( __( 'Invalid order ID.', 'woocommerce' ) ); } - $remaining_refund_amount = $order->get_remaining_refund_amount(); - $remaining_refund_items = $order->get_remaining_refund_items(); - $refund_item_count = 0; - $refund = new WC_Order_Refund( $args['refund_id'] ); + $remaining_refund_amount = $order->get_remaining_refund_amount(); + $remaining_refund_items = $order->get_remaining_refund_items(); + $refund_item_count = 0; + $refund = new WC_Order_Refund( $args['refund_id'] ); + $refunded_order_and_products = array(); if ( 0 > $args['amount'] || $args['amount'] > $remaining_refund_amount ) { throw new Exception( __( 'Invalid refund amount.', 'woocommerce' ) ); @@ -575,6 +577,16 @@ function wc_create_refund( $args = array() ) { continue; } + // array of order id and product id which were refunded. + // later to be used for revoking download permission. + // checking if the item is a product, as we only need to revoke download permission for products. + if ( $item->is_type( 'line_item' ) ) { + $refunded_order_and_products[ $item_id ] = array( + 'order_id' => $order->get_id(), + 'product_id' => $item->get_product_id(), + ); + } + $class = get_class( $item ); $refunded_item = new $class( $item ); $refunded_item->set_id( 0 ); @@ -634,6 +646,19 @@ function wc_create_refund( $args = array() ) { wc_restock_refunded_items( $order, $args['line_items'] ); } + // delete downloads that were refunded using order and product id, if present. + if ( ! empty( $refunded_order_and_products ) ) { + foreach ( $refunded_order_and_products as $refunded_order_and_product ) { + $download_data_store = WC_Data_Store::load( 'customer-download' ); + $downloads = $download_data_store->get_downloads( $refunded_order_and_product ); + if ( ! empty( $downloads ) ) { + foreach ( $downloads as $download ) { + $download_data_store->delete_by_id( $download->get_id() ); + } + } + } + } + /** * Trigger notification emails. * diff --git a/plugins/woocommerce/includes/wc-page-functions.php b/plugins/woocommerce/includes/wc-page-functions.php index 2b0a03f05e8..f80ac3e54a0 100644 --- a/plugins/woocommerce/includes/wc-page-functions.php +++ b/plugins/woocommerce/includes/wc-page-functions.php @@ -119,26 +119,32 @@ function wc_get_endpoint_url( $endpoint, $value = '', $permalink = '' ) { } /** - * Hide menu items conditionally. + * Hide or adjust menu items conditionally. * * @param array $items Navigation items. * @return array */ function wc_nav_menu_items( $items ) { - if ( ! is_user_logged_in() ) { - $customer_logout = get_option( 'woocommerce_logout_endpoint', 'customer-logout' ); + $logout_endpoint = get_option( 'woocommerce_logout_endpoint', 'customer-logout' ); - if ( ! empty( $customer_logout ) && ! empty( $items ) && is_array( $items ) ) { - foreach ( $items as $key => $item ) { - if ( empty( $item->url ) ) { - continue; - } - $path = wp_parse_url( $item->url, PHP_URL_PATH ) ?? ''; - $query = wp_parse_url( $item->url, PHP_URL_QUERY ) ?? ''; + if ( ! empty( $logout_endpoint ) && ! empty( $items ) && is_array( $items ) ) { + foreach ( $items as $key => $item ) { + if ( empty( $item->url ) ) { + continue; + } - if ( strstr( $path, $customer_logout ) || strstr( $query, $customer_logout ) ) { - unset( $items[ $key ] ); - } + $path = wp_parse_url( $item->url, PHP_URL_PATH ) ?? ''; + $query = wp_parse_url( $item->url, PHP_URL_QUERY ) ?? ''; + $is_logout_link = strstr( $path, $logout_endpoint ) || strstr( $query, $logout_endpoint ); + + if ( ! $is_logout_link ) { + continue; + } + + if ( is_user_logged_in() ) { + $items[ $key ]->url = wp_nonce_url( $item->url, 'customer-logout' ); + } else { + unset( $items[ $key ] ); } } } @@ -147,6 +153,40 @@ function wc_nav_menu_items( $items ) { } add_filter( 'wp_nav_menu_objects', 'wc_nav_menu_items', 10 ); +/** + * Hide menu items in navigation blocks conditionally. + * + * Does the same thing as wc_nav_menu_items but for block themes. + * + * @since 9.3.0 + * @param \WP_Block_list $inner_blocks Inner blocks. + * @return \WP_Block_list + */ +function wc_nav_menu_inner_blocks( $inner_blocks ) { + $logout_endpoint = get_option( 'woocommerce_logout_endpoint', 'customer-logout' ); + + if ( ! empty( $logout_endpoint ) && $inner_blocks ) { + foreach ( $inner_blocks as $inner_block_key => $inner_block ) { + $url = $inner_block->parsed_block['attrs']['url'] ?? ''; + $path = wp_parse_url( $url, PHP_URL_PATH ) ?? ''; + $query = wp_parse_url( $url, PHP_URL_QUERY ) ?? ''; + $is_logout_link = strstr( $path, $logout_endpoint ) || strstr( $query, $logout_endpoint ); + + if ( ! $is_logout_link ) { + continue; + } + + if ( is_user_logged_in() ) { + $inner_block->parsed_block['attrs']['url'] = wp_nonce_url( $inner_block->parsed_block['attrs']['url'], 'customer-logout' ); + } else { + unset( $inner_blocks[ $inner_block_key ] ); + } + } + } + + return $inner_blocks; +} +add_filter( 'block_core_navigation_render_inner_blocks', 'wc_nav_menu_inner_blocks' ); /** * Fix active class in nav for shop page. @@ -168,7 +208,7 @@ function wc_nav_menu_item_classes( $menu_items ) { $menu_id = (int) $menu_item->object_id; // Unset active class for blog page. - if ( $page_for_posts === $menu_id ) { + if ( $page_for_posts === $menu_id && isset( $menu_item->object ) && 'page' === $menu_item->object ) { $menu_items[ $key ]->current = false; if ( in_array( 'current_page_parent', $classes, true ) ) { diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php index 9e49568115e..7232ef7ccc7 100644 --- a/plugins/woocommerce/includes/wc-product-functions.php +++ b/plugins/woocommerce/includes/wc-product-functions.php @@ -662,8 +662,13 @@ function wc_product_has_global_unique_id( $product_id, $global_unique_id ) { return boolval( $has_global_unique_id ); } - $data_store = WC_Data_Store::load( 'product' ); - $global_unique_id_found = $data_store->is_existing_global_unique_id( $product_id, $global_unique_id ); + $data_store = WC_Data_Store::load( 'product' ); + if ( $data_store->has_callable( 'is_existing_global_unique_id' ) ) { + $global_unique_id_found = $data_store->is_existing_global_unique_id( $product_id, $global_unique_id ); + } else { + $logger = wc_get_logger(); + $logger->error( 'The method is_existing_global_unique_id is not implemented in the data store.', array( 'source' => 'wc_product_has_global_unique_id' ) ); + } /** * Gives plugins an opportunity to verify Unique ID uniqueness themselves. * @@ -738,11 +743,17 @@ function wc_get_product_id_by_sku( $sku ) { * * @since 9.1.0 * @param string $global_unique_id Product Unique ID. - * @return int + * @return int|null */ function wc_get_product_id_by_global_unique_id( $global_unique_id ) { $data_store = WC_Data_Store::load( 'product' ); - return $data_store->get_product_id_by_global_unique_id( $global_unique_id ); + if ( $data_store->has_callable( 'get_product_id_by_global_unique_id' ) ) { + return $data_store->get_product_id_by_global_unique_id( $global_unique_id ); + } else { + $logger = wc_get_logger(); + $logger->error( 'The method get_product_id_by_global_unique_id is not implemented in the data store.', array( 'source' => 'wc_get_product_id_by_global_unique_id' ) ); + } + return null; } /** diff --git a/plugins/woocommerce/includes/wc-stock-functions.php b/plugins/woocommerce/includes/wc-stock-functions.php index 4aee1011828..1489a0e6630 100644 --- a/plugins/woocommerce/includes/wc-stock-functions.php +++ b/plugins/woocommerce/includes/wc-stock-functions.php @@ -10,6 +10,8 @@ defined( 'ABSPATH' ) || exit; +use Automattic\WooCommerce\Checkout\Helpers\ReserveStock; + /** * Update a product's stock amount. * @@ -40,8 +42,12 @@ function wc_update_product_stock( $product, $stock_quantity = null, $operation = // Fire actions to let 3rd parties know the stock is about to be changed. if ( $product_with_stock->is_type( 'variation' ) ) { + // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment + /** This action is documented in includes/data-stores/class-wc-product-data-store-cpt.php */ do_action( 'woocommerce_variation_before_set_stock', $product_with_stock ); } else { + // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment + /** This action is documented in includes/data-stores/class-wc-product-data-store-cpt.php */ do_action( 'woocommerce_product_before_set_stock', $product_with_stock ); } @@ -58,8 +64,12 @@ function wc_update_product_stock( $product, $stock_quantity = null, $operation = // Fire actions to let 3rd parties know the stock changed. if ( $product_with_stock->is_type( 'variation' ) ) { + // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment + /** This action is documented in includes/data-stores/class-wc-product-data-store-cpt.php */ do_action( 'woocommerce_variation_set_stock', $product_with_stock ); } else { + // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment + /** This action is documented in includes/data-stores/class-wc-product-data-store-cpt.php */ do_action( 'woocommerce_product_set_stock', $product_with_stock ); } @@ -232,19 +242,23 @@ function wc_trigger_stock_change_notifications( $order, $changes ) { return; } - $order_notes = array(); - $no_stock_amount = absint( get_option( 'woocommerce_notify_no_stock_amount', 0 ) ); + $order_notes = array(); foreach ( $changes as $change ) { - $order_notes[] = $change['product']->get_formatted_name() . ' ' . $change['from'] . '→' . $change['to']; - $low_stock_amount = absint( wc_get_low_stock_amount( wc_get_product( $change['product']->get_id() ) ) ); - if ( $change['to'] <= $no_stock_amount ) { - do_action( 'woocommerce_no_stock', wc_get_product( $change['product']->get_id() ) ); - } elseif ( $change['to'] <= $low_stock_amount ) { - do_action( 'woocommerce_low_stock', wc_get_product( $change['product']->get_id() ) ); - } + $order_notes[] = $change['product']->get_formatted_name() . ' ' . $change['from'] . '→' . $change['to']; if ( $change['to'] < 0 ) { + /** + * Action fires when an item in an order is backordered. + * + * @since 3.0 + * + * @param array $args { + * @type WC_Product $product The product that is on backorder. + * @type int $order_id The ID of the order. + * @type int|float $quantity The amount of product on backorder. + * } + */ do_action( 'woocommerce_product_on_backorder', array( @@ -259,6 +273,48 @@ function wc_trigger_stock_change_notifications( $order, $changes ) { $order->add_order_note( __( 'Stock levels reduced:', 'woocommerce' ) . ' ' . implode( ', ', $order_notes ) ); } +/** + * Check if a product's stock quantity has reached certain thresholds and trigger appropriate actions. + * + * This functionality was moved out of `wc_trigger_stock_change_notifications` in order to decouple it from orders, + * since stock quantity can also be updated in other ways. + * + * @param WC_Product $product The product whose stock level has changed. + * + * @return void + */ +function wc_trigger_stock_change_actions( $product ) { + if ( true !== $product->get_manage_stock() ) { + return; + } + + $no_stock_amount = absint( get_option( 'woocommerce_notify_no_stock_amount', 0 ) ); + $low_stock_amount = absint( wc_get_low_stock_amount( $product ) ); + $stock_quantity = $product->get_stock_quantity(); + + if ( $stock_quantity <= $no_stock_amount ) { + /** + * Action fires when a product's stock quantity reaches the "no stock" threshold. + * + * @since 3.0 + * + * @param WC_Product $product The product whose stock quantity has changed. + */ + do_action( 'woocommerce_no_stock', $product ); + } elseif ( $stock_quantity <= $low_stock_amount ) { + /** + * Action fires when a product's stock quantity reaches the "low stock" threshold. + * + * @since 3.0 + * + * @param WC_Product $product The product whose stock quantity has changed. + */ + do_action( 'woocommerce_low_stock', $product ); + } +} +add_action( 'woocommerce_variation_set_stock', 'wc_trigger_stock_change_actions' ); +add_action( 'woocommerce_product_set_stock', 'wc_trigger_stock_change_actions' ); + /** * Increase stock levels for items within an order. * @@ -348,7 +404,8 @@ function wc_get_held_stock_quantity( WC_Product $product, $exclude_order_id = 0 return 0; } - return ( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->get_reserved_stock( $product, $exclude_order_id ); + $reserve_stock = new ReserveStock(); + return $reserve_stock->get_reserved_stock( $product, $exclude_order_id ); } /** @@ -374,7 +431,8 @@ function wc_reserve_stock_for_order( $order ) { $order = $order instanceof WC_Order ? $order : wc_get_order( $order ); if ( $order ) { - ( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->reserve_stock_for_order( $order ); + $reserve_stock = new ReserveStock(); + $reserve_stock->reserve_stock_for_order( $order ); } } add_action( 'woocommerce_checkout_order_created', 'wc_reserve_stock_for_order' ); @@ -400,7 +458,8 @@ function wc_release_stock_for_order( $order ) { $order = $order instanceof WC_Order ? $order : wc_get_order( $order ); if ( $order ) { - ( new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() )->release_stock_for_order( $order ); + $reserve_stock = new ReserveStock(); + $reserve_stock->release_stock_for_order( $order ); } } add_action( 'woocommerce_checkout_order_exception', 'wc_release_stock_for_order' ); @@ -426,8 +485,11 @@ function wc_get_low_stock_amount( WC_Product $product ) { $low_stock_amount = $product->get_low_stock_amount(); if ( '' === $low_stock_amount && $product->is_type( 'variation' ) ) { - $product = wc_get_product( $product->get_parent_id() ); - $low_stock_amount = $product->get_low_stock_amount(); + $parent_product = wc_get_product( $product->get_parent_id() ); + + if ( $parent_product instanceof WC_Product ) { + $low_stock_amount = $parent_product->get_low_stock_amount(); + } } if ( '' === $low_stock_amount ) { diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php index 6bd3be8ce29..5187fb20817 100644 --- a/plugins/woocommerce/includes/wc-template-functions.php +++ b/plugins/woocommerce/includes/wc-template-functions.php @@ -31,18 +31,18 @@ function wc_template_redirect() { if ( is_page( wc_get_page_id( 'checkout' ) ) && wc_get_page_id( 'checkout' ) !== wc_get_page_id( 'cart' ) && WC()->cart->is_empty() && empty( $wp->query_vars['order-pay'] ) && ! isset( $wp->query_vars['order-received'] ) && ! is_customize_preview() && apply_filters( 'woocommerce_checkout_redirect_empty_cart', true ) ) { wp_safe_redirect( wc_get_cart_url() ); exit; - } - // Logout. - if ( isset( $wp->query_vars['customer-logout'] ) && ! empty( $_REQUEST['_wpnonce'] ) && wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), 'customer-logout' ) ) { - wp_safe_redirect( str_replace( '&', '&', wp_logout_url( apply_filters( 'woocommerce_logout_default_redirect_url', wc_get_page_permalink( 'myaccount' ) ) ) ) ); - exit; - } - - // Redirect to the correct logout endpoint. - if ( isset( $wp->query_vars['customer-logout'] ) && 'true' === $wp->query_vars['customer-logout'] ) { - wp_safe_redirect( esc_url_raw( wc_get_account_endpoint_url( 'customer-logout' ) ) ); + // Logout endpoint under My Account page. Logging out requires a valid nonce. + if ( isset( $wp->query_vars['customer-logout'] ) ) { + if ( ! empty( $_REQUEST['_wpnonce'] ) && wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), 'customer-logout' ) ) { + wp_logout(); + wp_safe_redirect( wc_get_logout_redirect_url() ); + exit; + } + /* translators: %s: logout url */ + wc_add_notice( sprintf( __( 'Are you sure you want to log out? Confirm and log out', 'woocommerce' ), wc_logout_url() ) ); + wp_safe_redirect( wc_get_page_permalink( 'myaccount' ) ); exit; } @@ -237,11 +237,11 @@ function wc_set_loop_prop( $prop, $value = '' ) { } /** - * Set the current visbility for a product in the woocommerce_loop global. + * Set the current visibility for a product in the woocommerce_loop global. * * @since 4.4.0 - * @param int $product_id Product it to cache visbiility for. - * @param bool $value The poduct visibility value to cache. + * @param int $product_id Product it to cache visibility for. + * @param bool $value The product visibility value to cache. */ function wc_set_loop_product_visibility( $product_id, $value ) { wc_set_loop_prop( "product_visibility_$product_id", $value ); @@ -1387,6 +1387,10 @@ if ( ! function_exists( 'woocommerce_template_loop_add_to_cart' ) ) { ), ); + if ( is_a( $product, 'WC_Product_Simple' ) ) { + $defaults['attributes']['data-success_message'] = $product->add_to_cart_success_message(); + } + $args = apply_filters( 'woocommerce_loop_add_to_cart_args', wp_parse_args( $args, $defaults ), $product ); if ( ! empty( $args['attributes']['aria-describedby'] ) ) { @@ -1611,25 +1615,39 @@ function wc_get_gallery_image_html( $attachment_id, $main_image = false ) { $thumbnail_srcset = wp_get_attachment_image_srcset( $attachment_id, $thumbnail_size ); $full_src = wp_get_attachment_image_src( $attachment_id, $full_size ); $alt_text = trim( wp_strip_all_tags( get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ) ); - $image = wp_get_attachment_image( + + /** + * Filters the attributes for the image markup. + * + * @since 3.3.2 + * + * @param array $image_attributes Attributes for the image markup. + */ + $image_params = apply_filters( + 'woocommerce_gallery_image_html_attachment_image_params', + array( + 'title' => _wp_specialchars( get_post_field( 'post_title', $attachment_id ), ENT_QUOTES, 'UTF-8', true ), + 'data-caption' => _wp_specialchars( get_post_field( 'post_excerpt', $attachment_id ), ENT_QUOTES, 'UTF-8', true ), + 'data-src' => esc_url( $full_src[0] ), + 'data-large_image' => esc_url( $full_src[0] ), + 'data-large_image_width' => esc_attr( $full_src[1] ), + 'data-large_image_height' => esc_attr( $full_src[2] ), + 'class' => esc_attr( $main_image ? 'wp-post-image' : '' ), + ), + $attachment_id, + $image_size, + $main_image + ); + + if ( isset( $image_params['title'] ) ) { + unset( $image_params['title'] ); + } + + $image = wp_get_attachment_image( $attachment_id, $image_size, false, - apply_filters( - 'woocommerce_gallery_image_html_attachment_image_params', - array( - 'title' => _wp_specialchars( get_post_field( 'post_title', $attachment_id ), ENT_QUOTES, 'UTF-8', true ), - 'data-caption' => _wp_specialchars( get_post_field( 'post_excerpt', $attachment_id ), ENT_QUOTES, 'UTF-8', true ), - 'data-src' => esc_url( $full_src[0] ), - 'data-large_image' => esc_url( $full_src[0] ), - 'data-large_image_width' => esc_attr( $full_src[1] ), - 'data-large_image_height' => esc_attr( $full_src[2] ), - 'class' => esc_attr( $main_image ? 'wp-post-image' : '' ), - ), - $attachment_id, - $image_size, - $main_image - ) + $image_params ); return ''; @@ -2854,6 +2872,12 @@ if ( ! function_exists( 'woocommerce_form_field' ) ) { } if ( $args['required'] ) { + // hidden inputs are the only kind of inputs that don't need an `aria-required` attribute. + // checkboxes apply the `custom_attributes` to the label - we need to apply the attribute on the input itself, instead. + if ( ! in_array( $args['type'], array( 'hidden', 'checkbox' ), true ) ) { + $args['custom_attributes']['aria-required'] = 'true'; + } + $args['class'][] = 'validate-required'; $required = ' *'; } else { @@ -2978,12 +3002,13 @@ if ( ! function_exists( 'woocommerce_form_field' ) ) { } $field .= sprintf( - ' %6$s', + ' %7$s', esc_attr( $key ), esc_attr( $args['id'] ), esc_attr( $args['checked_value'] ), esc_attr( 'input-checkbox ' . implode( ' ', $args['input_class'] ) ), checked( $value, $args['checked_value'], false ), + $args['required'] ? ' aria-required="true"' : '', wp_kses_post( $args['label'] ) ); @@ -3728,22 +3753,31 @@ function wc_get_price_html_from_text() { } /** - * Get logout endpoint. + * Get the redirect URL after logging out. Defaults to the my account page. + * + * @since 9.3.0 + * @return string + */ +function wc_get_logout_redirect_url() { + /** + * Filters the logout redirect URL. + * + * @since 2.6.9 + * @param string $logout_url Logout URL. + * @return string + */ + return apply_filters( 'woocommerce_logout_default_redirect_url', wc_get_page_permalink( 'myaccount' ) ); +} + +/** + * Get logout link. * * @since 2.6.9 - * * @param string $redirect Redirect URL. - * * @return string */ function wc_logout_url( $redirect = '' ) { - $redirect = $redirect ? $redirect : apply_filters( 'woocommerce_logout_default_redirect_url', wc_get_page_permalink( 'myaccount' ) ); - - if ( get_option( 'woocommerce_logout_endpoint' ) ) { - return wp_nonce_url( wc_get_endpoint_url( 'customer-logout', '', $redirect ), 'customer-logout' ); - } - - return wp_logout_url( $redirect ); + return wp_logout_url( $redirect ? $redirect : wc_get_logout_redirect_url() ); } /** @@ -3771,7 +3805,7 @@ function wc_empty_cart_message() { // This adds the cart-empty classname to the notice to preserve backwards compatibility (for styling purposes etc). $notice = str_replace( 'class="woocommerce-info"', 'class="cart-empty woocommerce-info"', $notice ); - // Return the notice within a consistent wrapper element. This is targetted by some scripts such as cart.js. + // Return the notice within a consistent wrapper element. This is targeted by some scripts such as cart.js. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo '
' . $notice . '
'; } @@ -4019,3 +4053,45 @@ function wc_update_product_archive_title( $post_type_name, $post_type ) { add_filter( 'post_type_archive_title', 'wc_update_product_archive_title', 10, 2 ); // phpcs:enable Generic.Commenting.Todo.TaskFound + +/** + * Set the version of the hooked blocks in the database. Used when WC is installed for the first time. + * + * @since 9.2.0 + * + * @return void + */ +function wc_set_hooked_blocks_version() { + // Only set the version if the current theme is a block theme. + if ( ! wc_current_theme_is_fse_theme() && ! current_theme_supports( 'block-template-parts' ) ) { + return; + } + + $option_name = 'woocommerce_hooked_blocks_version'; + + if ( get_option( $option_name ) ) { + return; + } + + add_option( $option_name, WC()->version ); +} + +/** + * If the user switches from a classic to a block theme and they haven't already got a woocommerce_hooked_blocks_version, + * set the version of the hooked blocks in the database, or as "no" to disable all block hooks then set as the latest WC version. + * + * @since 9.2.0 + * + * @param string $old_name Old theme name. + * @param \WP_Theme $old_theme Instance of the old theme. + * @return void + */ +function wc_set_hooked_blocks_version_on_theme_switch( $old_name, $old_theme ) { + $option_name = 'woocommerce_hooked_blocks_version'; + $option_value = get_option( $option_name, false ); + + // Sites with the option value set to "no" have already been migrated, and block hooks have been disabled. Checking explicitly for false to avoid setting the option again. + if ( ! $old_theme->is_block_theme() && ( wc_current_theme_is_fse_theme() || current_theme_supports( 'block-template-parts' ) ) && false === $option_value ) { + add_option( $option_name, WC()->version ); + } +} diff --git a/plugins/woocommerce/includes/wc-template-hooks.php b/plugins/woocommerce/includes/wc-template-hooks.php index 22588ca1d2f..e9fa7da964f 100644 --- a/plugins/woocommerce/includes/wc-template-hooks.php +++ b/plugins/woocommerce/includes/wc-template-hooks.php @@ -319,3 +319,8 @@ add_action( 'woocommerce_before_customer_login_form', 'woocommerce_output_all_no add_action( 'woocommerce_before_lost_password_form', 'woocommerce_output_all_notices', 10 ); add_action( 'before_woocommerce_pay', 'woocommerce_output_all_notices', 10 ); add_action( 'woocommerce_before_reset_password_form', 'woocommerce_output_all_notices', 10 ); + +/** + * Hooked blocks. + */ +add_action( 'after_switch_theme', 'wc_set_hooked_blocks_version_on_theme_switch', 10, 2 ); diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php index ff5f6ee2feb..179cd988255 100644 --- a/plugins/woocommerce/includes/wc-update-functions.php +++ b/plugins/woocommerce/includes/wc-update-functions.php @@ -416,7 +416,7 @@ function wc_update_209_brazillian_state() { // phpcs:disable WordPress.DB.SlowDBQuery - // Update brazillian state codes. + // Update Brazilian state codes. $wpdb->update( $wpdb->postmeta, array( @@ -2598,7 +2598,7 @@ function wc_update_770_remove_multichannel_marketing_feature_options() { /** * Migrate transaction data which was being incorrectly stored in the postmeta table to HPOS tables. * - * @return bool Whether there are pending migration recrods. + * @return bool Whether there are pending migration records. */ function wc_update_810_migrate_transactional_metadata_for_hpos() { global $wpdb; @@ -2730,6 +2730,45 @@ function wc_update_910_add_launch_your_store_tour_option() { add_option( 'woocommerce_show_lys_tour', 'yes' ); } +/** + * Add woocommerce_hooked_blocks_version option for existing stores that are using a theme that supports the Block Hooks API + */ +function wc_update_920_add_wc_hooked_blocks_version_option() { + if ( ! wc_current_theme_is_fse_theme() && ! current_theme_supports( 'block-template-parts' ) ) { + return; + } + + $option_name = 'woocommerce_hooked_blocks_version'; + $option_value = get_option( $option_name ); + + // If the option already exists, we don't need to do anything. + if ( false !== $option_value ) { + return; + } + + /** + * A list of theme slugs to execute this with. + * We are applying this filter to allow for the list to be extended by third-parties who were already using it. + * + * @since 8.4.0 + */ + $theme_include_list = apply_filters( 'woocommerce_hooked_blocks_theme_include_list', array( 'Twenty Twenty-Four', 'Twenty Twenty-Three', 'Twenty Twenty-Two', 'Tsubaki', 'Zaino', 'Thriving Artist', 'Amulet', 'Tazza' ) ); + $active_theme_name = wp_get_theme()->get( 'Name' ); + $should_set_hooked_blocks_version = in_array( $active_theme_name, $theme_include_list, true ); + + if ( $should_set_hooked_blocks_version ) { + // Set 8.4.0 as the version for existing stores that are using a theme that supports the Block Hooks API. + // This will ensure that the Block Hooks API is enabled for these stores and works as expected. + // Existing stores that aren't running approved block themes will not have the Block Hooks API enabled. + add_option( $option_name, '8.4.0' ); + } else { + // For block themes that aren't approved themes set this option to "no" to completely disable hooked blocks. + // This means we can assume the absence of the option is when a site is switching from a classic theme to a block theme for the first time. + // Note: We have to use "no" instead of false since the latter is the default value for the option if it doesn't exist. + add_option( $option_name, 'no' ); + } +} + /** * Remove user meta associated with the keys '_last_order', '_order_count' and '_money_spent'. * @@ -2740,14 +2779,16 @@ function wc_update_910_add_launch_your_store_tour_option() { function wc_update_910_remove_obsolete_user_meta() { global $wpdb; - $deletions = $wpdb->query( " + $deletions = $wpdb->query( + " DELETE FROM $wpdb->usermeta WHERE meta_key IN ( '_last_order', '_order_count', '_money_spent' ) - " ); + " + ); $logger = wc_get_logger(); @@ -2776,3 +2817,38 @@ function wc_update_910_remove_obsolete_user_meta() { ); } } + +/** + * Add woocommerce_coming_soon option when it is not currently present. + */ +function wc_update_930_add_woocommerce_coming_soon_option() { + add_option( 'woocommerce_coming_soon', 'no' ); +} + +/** + * Migrate Launch Your Store tour meta keys to the woocommerce_meta user data fields. + */ +function wc_update_930_migrate_user_meta_for_launch_your_store_tour() { + // Rename `woocommerce_launch_your_store_tour_hidden` meta key to `woocommerce_admin_launch_your_store_tour_hidden`. + global $wpdb; + $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->usermeta} + SET meta_key = %s + WHERE meta_key = %s", + 'woocommerce_admin_launch_your_store_tour_hidden', + 'woocommerce_launch_your_store_tour_hidden' + ) + ); + + // Rename `woocommerce_coming_soon_banner_dismissed` meta key to `woocommerce_admin_coming_soon_banner_dismissed`. + $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->usermeta} + SET meta_key = %s + WHERE meta_key = %s", + 'woocommerce_admin_coming_soon_banner_dismissed', + 'woocommerce_coming_soon_banner_dismissed' + ) + ); +} diff --git a/plugins/woocommerce/includes/wccom-site/rest-api/endpoints/class-wc-rest-wccom-site-ssr-controller.php b/plugins/woocommerce/includes/wccom-site/rest-api/endpoints/class-wc-rest-wccom-site-ssr-controller.php index 0dbee4d8feb..886e8e286b2 100644 --- a/plugins/woocommerce/includes/wccom-site/rest-api/endpoints/class-wc-rest-wccom-site-ssr-controller.php +++ b/plugins/woocommerce/includes/wccom-site/rest-api/endpoints/class-wc-rest-wccom-site-ssr-controller.php @@ -55,7 +55,7 @@ class WC_REST_WCCOM_Site_SSR_Controller extends WC_REST_WCCOM_Site_Controller { } /** - * Generate SSR data and submit it to WooCommmerce.com. + * Generate SSR data and submit it to WooCommerce.com. * * @since 7.8.0 * @param WP_REST_Request $request Full details about the request. diff --git a/plugins/woocommerce/lib/packages/Detection/MobileDetect.php b/plugins/woocommerce/lib/packages/Detection/MobileDetect.php index d4c2d48c7c8..f7eb621e537 100644 --- a/plugins/woocommerce/lib/packages/Detection/MobileDetect.php +++ b/plugins/woocommerce/lib/packages/Detection/MobileDetect.php @@ -789,7 +789,7 @@ class MobileDetect 'SamsungBrowser' => 'SamsungBrowser/[VER]', 'Iron' => 'Iron/[VER]', // @note: Safari 7534.48.3 is actually Version 5.1. - // @note: On BlackBerry the Version is overwriten by the OS. + // @note: On BlackBerry the Version is overwritten by the OS. 'Safari' => ['Version/[VER]', 'Safari/[VER]'], 'Skyfire' => 'Skyfire/[VER]', 'Tizen' => 'Tizen/[VER]', diff --git a/plugins/woocommerce/package.json b/plugins/woocommerce/package.json index afc80c04526..7ed07ba6aba 100644 --- a/plugins/woocommerce/package.json +++ b/plugins/woocommerce/package.json @@ -2,7 +2,7 @@ "name": "@woocommerce/plugin-woocommerce", "private": true, "title": "WooCommerce", - "version": "9.2.0", + "version": "9.4.0", "homepage": "https://woocommerce.com/", "repository": { "type": "git", @@ -51,10 +51,9 @@ "makepot": "composer run-script makepot", "packages:fix:textdomain": "node ./bin/package-update-textdomain.js", "test": "pnpm test:unit", - "test:api": "API_TEST_REPORT_DIR=\"$PWD/tests/api\" pnpm exec wc-api-tests test api", - "test:api-pw": "USE_WP_ENV=1 pnpm playwright test --config=tests/api-core-tests/playwright.config.js", - "test:e2e-pw": "pnpm test:e2e:install && pnpm playwright test --config=tests/e2e-pw/envs/default/playwright.config.js", - "test:e2e": "pnpm test:e2e:install && pnpm test:e2e:with-env default", + "test:api": "pnpm test:e2e:default --project=api --workers 4", + "test:e2e": "pnpm test:e2e:default --project=ui", + "test:e2e:default": "pnpm test:e2e:install && pnpm test:e2e:with-env default", "test:e2e:install": "pnpm playwright install chromium", "test:e2e:blocks": "pnpm --filter='@woocommerce/block-library' test:e2e", "test:e2e:with-env": "pnpm test:e2e:install && bash ./tests/e2e-pw/run-tests-with-env.sh", @@ -73,7 +72,8 @@ "update-wp-env": "php ./tests/e2e-pw/bin/update-wp-env.php", "watch:build": "pnpm --if-present --workspace-concurrency=Infinity --filter=\"$npm_package_name...\" --parallel '/^watch:build:project:.*$/'", "watch:build:project": "pnpm --if-present run '/^watch:build:project:.*$/'", - "watch:build:project:copy-assets": "wireit" + "watch:build:project:copy-assets": "wireit", + "wp-env": "wp-env" }, "lint-staged": { "*.php": [ @@ -92,61 +92,32 @@ "command": "lint:changes:branch ", "changes": [ "composer.lock", - "includes/**/*.php", - "patterns/**/*.php", - "src/**/*.php", - "templates/**/*.php", - "tests/php/**/*.php", - "tests/legacy/unit-tests/**/*.php", - "tests/unit-tests/**/*.php", - "tests/**/*.js" + "**/*.php", + "**/*.js" ] }, "tests": [ { - "name": "PHP", + "name": "PHP: 8.0 WP: latest", "testType": "unit:php", "command": "test:php:env", - "changes": [ - "client/admin/config/*.json", - "composer.json", - "composer.lock", - "includes/**/*.php", - "patterns/**/*.php", - "src/**/*.php", - "templates/**/*.php", - "tests/php/**/*.php", - "tests/legacy/unit-tests/**/*.php", - "tests/unit-tests/**/*.php" + "shardingArguments": [ + "--testsuite=wc-phpunit-legacy", + "--testsuite=wc-phpunit-main" ], - "testEnv": { - "start": "env:test" - }, - "events": [ - "pull_request", - "push" - ] - }, - { - "name": "PHP 8.0", - "testType": "unit:php", - "command": "test:php:env", "changes": [ "client/admin/config/*.json", "composer.json", "composer.lock", - "includes/**/*.php", - "patterns/**/*.php", - "src/**/*.php", - "templates/**/*.php", - "tests/php/**/*.php", - "tests/legacy/unit-tests/**/*.php", - "tests/unit-tests/**/*.php" + "**/*.php", + ".wp-env.json", + "phpunit.xml" ], "testEnv": { "start": "env:test", "config": { - "phpVersion": "8.0" + "phpVersion": "8.0", + "wpVersion": "latest" } }, "events": [ @@ -158,17 +129,17 @@ "name": "PHP WP: latest - 1", "testType": "unit:php", "command": "test:php:env", + "shardingArguments": [ + "--testsuite=wc-phpunit-legacy", + "--testsuite=wc-phpunit-main" + ], "changes": [ "client/admin/config/*.json", "composer.json", "composer.lock", - "includes/**/*.php", - "patterns/**/*.php", - "src/**/*.php", - "templates/**/*.php", - "tests/php/**/*.php", - "tests/legacy/unit-tests/**/*.php", - "tests/unit-tests/**/*.php" + "**/*.php", + ".wp-env.json", + "phpunit.xml" ], "testEnv": { "start": "env:test", @@ -181,49 +152,22 @@ "push" ] }, - { - "name": "PHP WP: latest - 2", - "testType": "unit:php", - "command": "test:php:env", - "changes": [ - "client/admin/config/*.json", - "composer.json", - "composer.lock", - "includes/**/*.php", - "patterns/**/*.php", - "src/**/*.php", - "templates/**/*.php", - "tests/php/**/*.php", - "tests/legacy/unit-tests/**/*.php", - "tests/unit-tests/**/*.php" - ], - "testEnv": { - "start": "env:test", - "config": { - "wpVersion": "latest-2" - } - }, - "events": [ - "pull_request", - "push" - ] - }, { "name": "PHP WP: nightly", "testType": "unit:php", "command": "test:php:env", + "shardingArguments": [ + "--testsuite=wc-phpunit-legacy", + "--testsuite=wc-phpunit-main" + ], "optional": true, "changes": [ "client/admin/config/*.json", "composer.json", "composer.lock", - "includes/**/*.php", - "patterns/**/*.php", - "src/**/*.php", - "templates/**/*.php", - "tests/php/**/*.php", - "tests/legacy/unit-tests/**/*.php", - "tests/unit-tests/**/*.php" + "**/*.php", + ".wp-env.json", + "phpunit.xml" ], "testEnv": { "start": "env:test", @@ -239,7 +183,7 @@ { "name": "Core e2e tests", "testType": "e2e", - "command": "test:e2e:with-env default", + "command": "test:e2e", "shardingArguments": [ "--shard=1/6", "--shard=2/6", @@ -256,7 +200,10 @@ "patterns/**/*.php", "src/**/*.php", "templates/**/*.php", - "tests/e2e-pw/**" + "tests/e2e-pw/**", + ".wp-env.json", + "woocommerce.php", + "uninstall.php" ], "testEnv": { "start": "env:test" @@ -382,28 +329,13 @@ { "name": "Core e2e tests - HPOS disabled", "testType": "e2e", - "command": "test:e2e:with-env default", - "shardingArguments": [ - "--shard=1/5", - "--shard=2/5", - "--shard=3/5", - "--shard=4/5", - "--shard=5/5" - ], + "command": "test:e2e:with-env default-hpos-disabled --project=ui", + "shardingArguments": [], "events": [ "daily-checks", "release-checks" ], - "changes": [ - "client/admin/config/*.json", - "composer.json", - "composer.lock", - "includes/**/*.php", - "patterns/**/*.php", - "src/**/*.php", - "templates/**/*.php", - "tests/e2e-pw/**" - ], + "changes": [], "testEnv": { "start": "env:test", "config": { @@ -411,7 +343,7 @@ } }, "report": { - "resultsBlobName": "core-e2e-reports-non-hpos", + "resultsBlobName": "core-e2e-reports-hpos-disabled", "resultsPath": "tests/e2e-pw/test-results", "allure": true } @@ -419,7 +351,7 @@ { "name": "Core e2e tests - PHP 8.1", "testType": "e2e", - "command": "test:e2e:with-env default", + "command": "test:e2e", "shardingArguments": [ "--shard=1/5", "--shard=2/5", @@ -447,7 +379,7 @@ { "name": "Core e2e tests - WP latest-1", "testType": "e2e", - "command": "test:e2e:with-env default", + "command": "test:e2e", "shardingArguments": [ "--shard=1/5", "--shard=2/5", @@ -475,7 +407,7 @@ { "name": "Core API tests", "testType": "api", - "command": "test:api-pw", + "command": "test:api", "optional": false, "changes": [ "client/admin/config/*.json", @@ -485,8 +417,9 @@ "patterns/**/*.php", "src/**/*.php", "templates/**/*.php", - "tests/api-core-tests/**", - "tests/e2e-pw/bin/**" + ".wp-env.json", + "woocommerce.php", + "uninstall.php" ], "testEnv": { "start": "env:test" @@ -497,14 +430,14 @@ ], "report": { "resultsBlobName": "core-api-report", - "resultsPath": "tests/api-core-tests/test-results", + "resultsPath": "tests/e2e-pw/test-results", "allure": true } }, { "name": "Core API tests - HPOS disabled", "testType": "api", - "command": "test:api-pw", + "command": "test:e2e:with-env default-hpos-disabled --project=api", "optional": false, "changes": [ "client/admin/config/*.json", @@ -514,8 +447,10 @@ "patterns/**/*.php", "src/**/*.php", "templates/**/*.php", - "tests/api-core-tests/**", - "tests/e2e-pw/bin/**" + "tests/e2e-pw/bin/**", + ".wp-env.json", + "woocommerce.php", + "uninstall.php" ], "events": [ "push" @@ -528,7 +463,7 @@ }, "report": { "resultsBlobName": "core-api-report-hpos-disabled", - "resultsPath": "tests/api-core-tests/test-results", + "resultsPath": "tests/e2e-pw/test-results", "allure": true } }, @@ -545,7 +480,10 @@ "patterns/**/*.php", "src/**/*.php", "templates/**/*.php", - "tests/performance/**" + "tests/performance/**", + ".wp-env.json", + "woocommerce.php", + "uninstall.php" ], "testEnv": { "start": "env:perf" @@ -568,11 +506,16 @@ "src/**/*.php", "templates/**/*.php", "templates/**/*.html", - "tests/metrics/**" + "tests/metrics/**", + ".wp-env.json" ], "events": [ - "disabled" - ] + "push" + ], + "report": { + "resultsBlobName": "core-metrics-report", + "resultsPath": "../../tools/compare-perf/artifacts/" + } }, { "name": "Blocks e2e tests", @@ -616,13 +559,30 @@ "shardingArguments": [], "changes": [], "events": [ - "daily-checks" + "daily-checks", + "on-demand" ], "report": { "resultsBlobName": "default-pressable-core-e2e", "resultsPath": "tests/e2e-pw/test-results", "allure": true } + }, + { + "name": "Core e2e tests - default WPCOM site", + "testType": "e2e", + "command": "test:e2e:with-env default-wpcom", + "shardingArguments": [], + "changes": [], + "events": [ + "daily-checks", + "on-demand" + ], + "report": { + "resultsBlobName": "default-wpcom-core-e2e", + "resultsPath": "tests/e2e-pw/test-results", + "allure": true + } } ] } @@ -632,7 +592,7 @@ "@babel/core": "7.12.9", "@babel/preset-env": "7.12.7", "@babel/register": "7.12.1", - "@playwright/test": "^1.45.1", + "@playwright/test": "^1.46.1", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/experimental-utils": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", @@ -646,8 +606,8 @@ "@woocommerce/woocommerce-rest-api": "^1.0.1", "@wordpress/babel-plugin-import-jsx-pragma": "1.1.3", "@wordpress/babel-preset-default": "3.0.2", - "@wordpress/e2e-test-utils-playwright": "wp-6.4", - "@wordpress/env": "^9.0.7", + "@wordpress/e2e-test-utils-playwright": "wp-6.6", + "@wordpress/env": "^10.1.0", "@wordpress/stylelint-config": "^21.36.0", "allure-commandline": "^2.25.0", "allure-playwright": "^2.9.2", @@ -679,7 +639,7 @@ }, "engines": { "node": "^20.11.1", - "pnpm": "^9.1.0" + "pnpm": "9.1.3" }, "browserslist": [ "> 0.1%", diff --git a/plugins/woocommerce/patterns/banner.php b/plugins/woocommerce/patterns/banner.php index 09d145e6299..11da651b411 100644 --- a/plugins/woocommerce/patterns/banner.php +++ b/plugins/woocommerce/patterns/banner.php @@ -27,9 +27,9 @@ $second_description = $content['descriptions'][1]['default'] ?? '';

- -

- + +

+

diff --git a/plugins/woocommerce/patterns/coming-soon-entire-site.php b/plugins/woocommerce/patterns/coming-soon-entire-site.php index e168886d453..6853b90d637 100644 --- a/plugins/woocommerce/patterns/coming-soon-entire-site.php +++ b/plugins/woocommerce/patterns/coming-soon-entire-site.php @@ -19,8 +19,8 @@ if ( 'twentytwentyfour' === $current_theme ) { } ?> - -
+ +
@@ -60,140 +60,5 @@ if ( 'twentytwentyfour' === $current_theme ) {
-
+
diff --git a/plugins/woocommerce/patterns/coming-soon-store-only.php b/plugins/woocommerce/patterns/coming-soon-store-only.php index b84d6e6a192..ad54ce0ccc9 100644 --- a/plugins/woocommerce/patterns/coming-soon-store-only.php +++ b/plugins/woocommerce/patterns/coming-soon-store-only.php @@ -20,8 +20,8 @@ if ( 'twentytwentyfour' === $current_theme ) { ?> - -
+ +
'; } ?> - -
+
diff --git a/plugins/woocommerce/patterns/content-right-image-left.php b/plugins/woocommerce/patterns/content-right-image-left.php index baa05e426d0..f6959940329 100644 --- a/plugins/woocommerce/patterns/content-right-image-left.php +++ b/plugins/woocommerce/patterns/content-right-image-left.php @@ -9,9 +9,9 @@ declare(strict_types=1); use Automattic\WooCommerce\Blocks\AIContent\PatternsHelper; -$header = __( 'Discover a world of possibilities', 'woocommerce' ); -$content = __( 'Welcome to a world of limitless possibilities, where the journey is as exhilarating as the destination, and where every moment is an opportunity to make your mark on the canvas of existence. The only limit is the extent of your imagination.', 'woocommerce' ); -$button = __( 'Get Started', 'woocommerce' ); +$header = __( 'Committed to a greener lifestyle', 'woocommerce' ); +$content = __( "Our passion is crafting mindful moments with locally sourced, organic, and sustainable products. We're more than a store; we're your path to a community-driven, eco-friendly lifestyle that embraces premium quality.", 'woocommerce' ); +$button = __( 'Meet us', 'woocommerce' ); $image_0 = PatternsHelper::get_image_url( $images, 0, 'assets/images/pattern-placeholders/drinkware-liquid-tableware-dishware-bottle-fluid.jpg' ); ?> diff --git a/plugins/woocommerce/patterns/featured-category-cover-image.php b/plugins/woocommerce/patterns/featured-category-cover-image.php index 0b9d5b6d7ea..fbc70579df4 100644 --- a/plugins/woocommerce/patterns/featured-category-cover-image.php +++ b/plugins/woocommerce/patterns/featured-category-cover-image.php @@ -14,7 +14,7 @@ $description = $content['descriptions'][0]['default'] ?? ''; $button = $content['buttons'][0]['default'] ?? ''; ?> - +
diff --git a/plugins/woocommerce/patterns/filters.php b/plugins/woocommerce/patterns/filters.php new file mode 100644 index 00000000000..f386dfbad44 --- /dev/null +++ b/plugins/woocommerce/patterns/filters.php @@ -0,0 +1,70 @@ + + + +
+

+ + + +
+
+ + + +
+

+ + + +
+
+ + + +
+

+ + + +
+
+ + + +
+

+ + +attribute_id; +} +?> + + +
+
+ + + +
+

+ + + +
+
+ diff --git a/plugins/woocommerce/patterns/four-image-grid-content-left.php b/plugins/woocommerce/patterns/four-image-grid-content-left.php index 383e5aedc5f..30e05b32f52 100644 --- a/plugins/woocommerce/patterns/four-image-grid-content-left.php +++ b/plugins/woocommerce/patterns/four-image-grid-content-left.php @@ -18,14 +18,12 @@ $image_3 = PatternsHelper::get_image_url( $images, 0, 'assets/images/pattern-pla ?> - - - +
- +

diff --git a/plugins/woocommerce/patterns/header-centered-pattern.php b/plugins/woocommerce/patterns/header-centered-pattern.php index 8a7c50fefee..62ab1540f15 100644 --- a/plugins/woocommerce/patterns/header-centered-pattern.php +++ b/plugins/woocommerce/patterns/header-centered-pattern.php @@ -11,8 +11,8 @@
- -
+ +
diff --git a/plugins/woocommerce/patterns/header-essential.php b/plugins/woocommerce/patterns/header-essential.php index 82fc3f768f6..5521b691efc 100644 --- a/plugins/woocommerce/patterns/header-essential.php +++ b/plugins/woocommerce/patterns/header-essential.php @@ -17,14 +17,14 @@
+ +
- -
diff --git a/plugins/woocommerce/patterns/header-minimal.php b/plugins/woocommerce/patterns/header-minimal.php index 8d42656b815..18ca4db4a37 100644 --- a/plugins/woocommerce/patterns/header-minimal.php +++ b/plugins/woocommerce/patterns/header-minimal.php @@ -19,6 +19,9 @@
+ + +
diff --git a/plugins/woocommerce/patterns/heading-with-three-columns-of-content-with-link.php b/plugins/woocommerce/patterns/heading-with-three-columns-of-content-with-link.php index 10af0171142..ea0b91ac4de 100644 --- a/plugins/woocommerce/patterns/heading-with-three-columns-of-content-with-link.php +++ b/plugins/woocommerce/patterns/heading-with-three-columns-of-content-with-link.php @@ -20,8 +20,8 @@ $button_link = __( 'Get started', 'woocommerce' ); -
-

+
+

diff --git a/plugins/woocommerce/patterns/hero-product-3-split.php b/plugins/woocommerce/patterns/hero-product-3-split.php index 4fb7f78c5a8..3da1d1619bc 100644 --- a/plugins/woocommerce/patterns/hero-product-3-split.php +++ b/plugins/woocommerce/patterns/hero-product-3-split.php @@ -50,7 +50,7 @@ $third_description = $content['descriptions'][2]['default'] ?? '';
-

+

diff --git a/plugins/woocommerce/patterns/hero-product-chessboard.php b/plugins/woocommerce/patterns/hero-product-chessboard.php index 521f4ccf355..074fd5afc68 100644 --- a/plugins/woocommerce/patterns/hero-product-chessboard.php +++ b/plugins/woocommerce/patterns/hero-product-chessboard.php @@ -21,7 +21,7 @@ $third_description = $content['descriptions'][2]['default'] ?? ''; $button = $content['buttons'][0]['default'] ?? ''; ?> - +
diff --git a/plugins/woocommerce/patterns/product-collection-4-columns.php b/plugins/woocommerce/patterns/product-collection-4-columns.php index 5162999a59e..c74d10bdd96 100644 --- a/plugins/woocommerce/patterns/product-collection-4-columns.php +++ b/plugins/woocommerce/patterns/product-collection-4-columns.php @@ -8,7 +8,7 @@ $products_title = $content['titles'][0]['default'] ?? ''; ?> - +
@@ -18,6 +18,10 @@ $products_title = $content['titles'][0]['default'] ?? '';

+ + + +
diff --git a/plugins/woocommerce/patterns/product-collection-5-columns.php b/plugins/woocommerce/patterns/product-collection-5-columns.php index 8bcc01d3106..679aa7b8e49 100644 --- a/plugins/woocommerce/patterns/product-collection-5-columns.php +++ b/plugins/woocommerce/patterns/product-collection-5-columns.php @@ -18,6 +18,10 @@ $products_title = $content['titles'][0]['default'] ?? '';

+ + + +
diff --git a/plugins/woocommerce/patterns/product-collection-featured-products-5-columns.php b/plugins/woocommerce/patterns/product-collection-featured-products-5-columns.php index 0b0af2e252b..eb4fdd8a6d4 100644 --- a/plugins/woocommerce/patterns/product-collection-featured-products-5-columns.php +++ b/plugins/woocommerce/patterns/product-collection-featured-products-5-columns.php @@ -20,6 +20,10 @@ $collection_title = $content['titles'][0]['default'] ?? ''; + + + +
diff --git a/plugins/woocommerce/patterns/product-query-product-gallery.php b/plugins/woocommerce/patterns/product-query-product-gallery.php index f1d7dacd549..4aa243a0ea7 100644 --- a/plugins/woocommerce/patterns/product-query-product-gallery.php +++ b/plugins/woocommerce/patterns/product-query-product-gallery.php @@ -19,6 +19,10 @@ $products_title = $content['titles'][0]['default'] ?? '';

+ + + +
diff --git a/plugins/woocommerce/patterns/social-follow-us-in-social-media.php b/plugins/woocommerce/patterns/social-follow-us-in-social-media.php index afcfd73a82c..ce7e871f649 100644 --- a/plugins/woocommerce/patterns/social-follow-us-in-social-media.php +++ b/plugins/woocommerce/patterns/social-follow-us-in-social-media.php @@ -41,6 +41,10 @@ $social_title = $content['titles'][0]['default'] ?? '';
+ + + +
diff --git a/plugins/woocommerce/patterns/testimonials-3-columns.php b/plugins/woocommerce/patterns/testimonials-3-columns.php index bedaf53adb1..c659a0600ad 100644 --- a/plugins/woocommerce/patterns/testimonials-3-columns.php +++ b/plugins/woocommerce/patterns/testimonials-3-columns.php @@ -24,6 +24,10 @@ $third_description = $content['descriptions'][2]['default'] ?? '';

+ + + +
@@ -37,7 +41,7 @@ $third_description = $content['descriptions'][2]['default'] ?? ''; -

~ Sophia K.

+

Sophia K.

@@ -54,7 +58,7 @@ $third_description = $content['descriptions'][2]['default'] ?? ''; -

~ Liam M.

+

Liam M.

@@ -70,7 +74,7 @@ $third_description = $content['descriptions'][2]['default'] ?? ''; -

~ Ava L.

+

Ava L.

diff --git a/plugins/woocommerce/patterns/testimonials-single.php b/plugins/woocommerce/patterns/testimonials-single.php index 0f5df3a6c73..8b495b788d2 100644 --- a/plugins/woocommerce/patterns/testimonials-single.php +++ b/plugins/woocommerce/patterns/testimonials-single.php @@ -28,8 +28,8 @@ $description = $content['descriptions'][0]['default'] ?? '';
- -
+ +

@@ -39,7 +39,7 @@ $description = $content['descriptions'][0]['default'] ?? ''; -

– Monica P.

+

Monica P.

diff --git a/plugins/woocommerce/patterns/three-columns-with-images-and-content.php b/plugins/woocommerce/patterns/three-columns-with-images-and-content.php index c0852e91fdc..7aa31d055a6 100644 --- a/plugins/woocommerce/patterns/three-columns-with-images-and-content.php +++ b/plugins/woocommerce/patterns/three-columns-with-images-and-content.php @@ -23,8 +23,8 @@ $image_2 = PatternsHelper::get_image_url( $images, 0, 'assets/images/patte -
-

+
+

diff --git a/plugins/woocommerce/phpcs.xml b/plugins/woocommerce/phpcs.xml index 0de1018b4f8..3f0931e607c 100644 --- a/plugins/woocommerce/phpcs.xml +++ b/plugins/woocommerce/phpcs.xml @@ -50,6 +50,26 @@ tests/ + + src/ + tests/php/src/ + plugins/woocommerce/src/Admin/Notes/DeprecatedNotes.php + plugins/woocommerce/tests/php/src/Proxies/ExampleClasses/ + + + + + + + includes/ + lib/ + src/ + tests/ + plugins/woocommerce/includes/admin/views/ + plugins/woocommerce/includes/emails/ + plugins/woocommerce/includes/react-admin/emails/ + + tests/src diff --git a/plugins/woocommerce/phpunit.xml b/plugins/woocommerce/phpunit.xml index 608fe200f14..9038c56f82d 100644 --- a/plugins/woocommerce/phpunit.xml +++ b/plugins/woocommerce/phpunit.xml @@ -9,20 +9,13 @@ verbose="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"> - + ./tests/legacy/unit-tests + + ./tests/php ./tests/php/helpers - - ./tests/legacy/unit-tests - ./tests/php - ./tests/legacy/unit-tests/woocommerce-admin - ./tests/php/helpers - - - ./tests/legacy/unit-tests/woocommerce-admin - diff --git a/plugins/woocommerce/readme.txt b/plugins/woocommerce/readme.txt index 5afa8c43c70..d3730c2db5b 100644 --- a/plugins/woocommerce/readme.txt +++ b/plugins/woocommerce/readme.txt @@ -1,10 +1,10 @@ === WooCommerce === Contributors: automattic, woocommerce, mikejolley, jameskoster, claudiosanches, rodrigosprimo, peterfabian1000, vedjain, jamosova, obliviousharmony, konamiman, sadowski, wpmuguru, royho, barryhughes-1, claudiulodro, tiagonoronha, ryelle, levinmedia, aljullu, nerrad, joshuawold, assassinateur, haszari, mppfeiffer, nielslange, opr18, ralucastn, tjcafferkey, danielwrobert, patriciahillebrandt, albarin, dinhtungdu, imanish003, karolmanijak, sunyatasattva, alexandrelara, gigitux, danieldudzic, samueljseay, alexflorisca, opr18, tarunvijwani, pauloarromba, saadtarhi, bor0, kloon, coreymckrill, jorgeatorres, leifsinger Tags: online store, ecommerce, shop, shopping cart, sell online -Requires at least: 6.4 -Tested up to: 6.5 +Requires at least: 6.5 +Tested up to: 6.6 Requires PHP: 7.4 -Stable tag: 9.1.0 +Stable tag: 9.2.3 License: GPLv3 License URI: https://www.gnu.org/licenses/gpl-3.0.html @@ -169,6 +169,6 @@ WooCommerce comes with some sample data you can use to see how products look; im == Changelog == -= 9.2.0 2024-XX-XX = += 9.4.0 2024-XX-XX = [See changelog for all versions](https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/changelog.txt). diff --git a/plugins/woocommerce/src/Admin/API/AI/AIEndpoint.php b/plugins/woocommerce/src/Admin/API/AI/AIEndpoint.php new file mode 100644 index 00000000000..12ea82b0fc5 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/AI/AIEndpoint.php @@ -0,0 +1,57 @@ +namespace, + '/' . $this->rest_base . '/' . $this->endpoint, + $args + ); + } + + /** + * Return schema properties. + * + * @return array + */ + public function get_schema() { + return array(); + } +} diff --git a/plugins/woocommerce/src/Admin/API/AI/BusinessDescription.php b/plugins/woocommerce/src/Admin/API/AI/BusinessDescription.php new file mode 100644 index 00000000000..430e695f7b0 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/AI/BusinessDescription.php @@ -0,0 +1,84 @@ +register( + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'update_business_description' ), + 'permission_callback' => array( Middleware::class, 'is_authorized' ), + 'args' => array( + 'business_description' => array( + 'description' => __( 'The business description for a given store.', 'woocommerce' ), + 'type' => 'string', + ), + ), + ), + 'schema' => array( $this, 'get_schema' ), + ) + ); + } + + /** + * Update the business description. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response object. + */ + public function update_business_description( $request ) { + + $business_description = $request->get_param( 'business_description' ); + + if ( ! $business_description ) { + return new WP_Error( + 'invalid_business_description', + __( 'Invalid business description.', 'woocommerce' ) + ); + } + + update_option( 'last_business_description_with_ai_content_generated', $business_description ); + + return rest_ensure_response( + array( + 'ai_content_generated' => true, + ) + ); + } + + /** + * Get the Business Description response. + * + * @return array + */ + public function get_schema() { + return array( + 'ai_content_generated' => true, + ); + } +} diff --git a/plugins/woocommerce/src/Admin/API/AI/Images.php b/plugins/woocommerce/src/Admin/API/AI/Images.php new file mode 100644 index 00000000000..8ac0f7023f4 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/AI/Images.php @@ -0,0 +1,105 @@ +register( + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'generate_images' ), + 'permission_callback' => array( Middleware::class, 'is_authorized' ), + 'args' => array( + 'business_description' => array( + 'description' => __( 'The business description for a given store.', 'woocommerce' ), + 'type' => 'string', + ), + ), + ), + ) + ); + } + + /** + * Generate Images from Pexels + * + * @param WP_REST_Request $request Request object. + * + * @return WP_Error|WP_REST_Response + */ + public function generate_images( WP_REST_Request $request ) { + + $business_description = sanitize_text_field( wp_unslash( $request['business_description'] ) ); + + if ( empty( $business_description ) ) { + $business_description = get_option( 'woo_ai_describe_store_description' ); + } + + $last_business_description = get_option( 'last_business_description_with_ai_content_generated' ); + + if ( $last_business_description === $business_description ) { + return rest_ensure_response( + array( + 'ai_content_generated' => true, + 'images' => array(), + ), + ); + } + + $ai_connection = new Connection(); + + $site_id = $ai_connection->get_site_id(); + + if ( is_wp_error( $site_id ) ) { + return $site_id; + } + + $token = $ai_connection->get_jwt_token( $site_id ); + + if ( is_wp_error( $token ) ) { + return $token; + } + + $images = ( new Pexels() )->get_images( $ai_connection, $token, $business_description ); + + if ( is_wp_error( $images ) ) { + $images = array( + 'images' => array(), + 'search_term' => '', + ); + } + + return rest_ensure_response( + array( + 'ai_content_generated' => true, + 'images' => $images, + ) + ); + } +} diff --git a/plugins/woocommerce/src/Admin/API/AI/Middleware.php b/plugins/woocommerce/src/Admin/API/AI/Middleware.php new file mode 100644 index 00000000000..10a7b6db94a --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/AI/Middleware.php @@ -0,0 +1,51 @@ +getErrorCode(), + $error->getMessage(), + array( 'status' => $error->getCode() ) + ); + } + + $allow_ai_connection = get_option( 'woocommerce_blocks_allow_ai_connection' ); + + if ( ! $allow_ai_connection ) { + try { + throw new RouteException( 'ai_connection_not_allowed', __( 'AI content generation is not allowed on this store. Update your store settings if you wish to enable this feature.', 'woocommerce' ), 403 ); + } catch ( RouteException $error ) { + return new WP_Error( + $error->getErrorCode(), + $error->getMessage(), + array( 'status' => $error->getCode() ) + ); + } + } + + return true; + } +} diff --git a/plugins/woocommerce/src/Admin/API/AI/Patterns.php b/plugins/woocommerce/src/Admin/API/AI/Patterns.php new file mode 100644 index 00000000000..224a7afb163 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/AI/Patterns.php @@ -0,0 +1,98 @@ +register( + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'update_patterns' ), + 'permission_callback' => array( Middleware::class, 'is_authorized' ), + 'args' => array( + 'business_description' => array( + 'description' => __( 'The business description for a given store.', 'woocommerce' ), + 'type' => 'string', + ), + 'images' => array( + 'description' => __( 'The images for a given store.', 'woocommerce' ), + 'type' => 'object', + ), + ), + ), + array( + 'methods' => \WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_patterns' ), + 'permission_callback' => array( Middleware::class, 'is_authorized' ), + ), + ) + ); + } + + /** + * Update patterns with the content and images powered by AI. + * + * @param WP_REST_Request $request Request object. + * + * @return WP_Error|WP_REST_Response + */ + public function update_patterns( WP_REST_Request $request ) { + $business_description = sanitize_text_field( wp_unslash( $request['business_description'] ) ); + + $ai_connection = new Connection(); + + $site_id = $ai_connection->get_site_id(); + + if ( is_wp_error( $site_id ) ) { + return $site_id; + } + + $token = $ai_connection->get_jwt_token( $site_id ); + + $images = $request['images']; + + try { + ( new UpdatePatterns() )->generate_content( $ai_connection, $token, $images, $business_description ); + return rest_ensure_response( array( 'ai_content_generated' => true ) ); + } catch ( \Exception $e ) { + return rest_ensure_response( array( 'ai_content_generated' => false ) ); + } + } + + /** + * Remove patterns generated by AI. + * + * @return WP_Error|WP_REST_Response + */ + public function delete_patterns() { + PatternsHelper::delete_patterns_ai_data_post(); + return rest_ensure_response( array( 'removed' => true ) ); + } +} diff --git a/plugins/woocommerce/src/Admin/API/AI/Product.php b/plugins/woocommerce/src/Admin/API/AI/Product.php new file mode 100644 index 00000000000..68f388cdc63 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/AI/Product.php @@ -0,0 +1,96 @@ +register( + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'update_product' ), + 'permission_callback' => array( Middleware::class, 'is_authorized' ), + 'args' => array( + 'products_information' => array( + 'description' => __( 'Data generated by AI for updating dummy products.', 'woocommerce' ), + 'type' => 'object', + ), + 'last_product' => array( + 'description' => __( 'Whether the product being updated is the last one in the loop', 'woocommerce' ), + 'type' => 'boolean', + ), + ), + ), + ) + ); + } + + /** + * Update product with the content and images powered by AI. + * + * @param WP_REST_Request $request Request object. + * + * @return WP_REST_Response + */ + public function update_product( WP_REST_Request $request ) { + $product_information = $request['products_information'] ?? array(); + + if ( empty( $product_information ) ) { + return rest_ensure_response( + array( + self::AI_CONTENT_GENERATED => true, + ) + ); + } + + try { + $product_updater = new UpdateProducts(); + $product_updater->update_product_content( $product_information ); + } catch ( \Exception $e ) { + return rest_ensure_response( array( 'ai_content_generated' => false ) ); + } + + $last_product_to_update = $request['last_product'] ?? false; + + if ( $last_product_to_update ) { + flush_rewrite_rules(); + } + + return rest_ensure_response( + array( + self::AI_CONTENT_GENERATED => true, + ) + ); + } +} diff --git a/plugins/woocommerce/src/Admin/API/AI/Products.php b/plugins/woocommerce/src/Admin/API/AI/Products.php new file mode 100644 index 00000000000..e764777ca76 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/AI/Products.php @@ -0,0 +1,128 @@ +register( + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'generate_products_content' ), + 'permission_callback' => array( Middleware::class, 'is_authorized' ), + 'args' => array( + 'business_description' => array( + 'description' => __( 'The business description for a given store.', 'woocommerce' ), + 'type' => 'string', + ), + 'images' => array( + 'description' => __( 'The images for a given store.', 'woocommerce' ), + 'type' => 'object', + ), + ), + ), + array( + 'methods' => \WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_products' ), + 'permission_callback' => array( Middleware::class, 'is_authorized' ), + ), + ) + ); + } + + /** + * Generate the content for the products. + * + * @param WP_REST_Request $request Request object. + * + * @return WP_Error|WP_REST_Response + */ + public function generate_products_content( WP_REST_Request $request ) { + $allow_ai_connection = get_option( 'woocommerce_blocks_allow_ai_connection' ); + + if ( ! $allow_ai_connection ) { + return rest_ensure_response( + new WP_Error( + 'ai_connection_not_allowed', + __( 'AI content generation is not allowed on this store. Update your store settings if you wish to enable this feature.', 'woocommerce' ) + ) + ); + } + + $business_description = sanitize_text_field( wp_unslash( $request['business_description'] ) ); + + if ( empty( $business_description ) ) { + $business_description = get_option( 'woo_ai_describe_store_description' ); + } + + $ai_connection = new Connection(); + + $site_id = $ai_connection->get_site_id(); + + if ( is_wp_error( $site_id ) ) { + return $site_id; + } + + $token = $ai_connection->get_jwt_token( $site_id ); + + if ( is_wp_error( $token ) ) { + return $token; + } + + $images = $request['images']; + + $populate_products = ( new UpdateProducts() )->generate_content( $ai_connection, $token, $images, $business_description ); + + if ( is_wp_error( $populate_products ) ) { + return $populate_products; + } + + if ( ! isset( $populate_products['product_content'] ) ) { + return new WP_Error( 'product_content_not_found', __( 'Product content not found.', 'woocommerce' ) ); + } + + $product_content = $populate_products['product_content']; + + $item = array( + 'ai_content_generated' => true, + 'product_content' => $product_content, + ); + + return rest_ensure_response( $item ); + } + + /** + * Remove products generated by AI. + * + * @return WP_Error|WP_REST_Response + */ + public function delete_products() { + ( new UpdateProducts() )->reset_products_content(); + return rest_ensure_response( array( 'removed' => true ) ); + } +} diff --git a/plugins/woocommerce/src/Admin/API/AI/StoreInfo.php b/plugins/woocommerce/src/Admin/API/AI/StoreInfo.php new file mode 100644 index 00000000000..0fb0fb3ab8c --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/AI/StoreInfo.php @@ -0,0 +1,81 @@ +register( + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_response' ), + 'permission_callback' => array( Middleware::class, 'is_authorized' ), + ), + 'schema' => array( $this, 'get_schema' ), + ) + ); + } + + /** + * Update the store title powered by AI. + * + * @return WP_Error|WP_REST_Response + */ + public function get_response() { + $product_updater = new UpdateProducts(); + $patterns = PatternsHelper::get_patterns_ai_data_post(); + + $products = $product_updater->fetch_product_ids( 'dummy' ); + + if ( empty( $products ) && ! isset( $patterns ) ) { + return rest_ensure_response( + array( + 'is_ai_generated' => false, + ) + ); + } + + return rest_ensure_response( + array( + 'is_ai_generated' => true, + ) + ); + } + + /** + * Get the Business Description response. + * + * @return array + */ + public function get_schema() { + return array( + 'ai_content_generated' => true, + ); + } +} diff --git a/plugins/woocommerce/src/Admin/API/AI/StoreTitle.php b/plugins/woocommerce/src/Admin/API/AI/StoreTitle.php new file mode 100644 index 00000000000..9c479027098 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/AI/StoreTitle.php @@ -0,0 +1,155 @@ +register( + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'update_store_title' ), + 'permission_callback' => array( Middleware::class, 'is_authorized' ), + 'args' => array( + 'business_description' => array( + 'description' => __( 'The business description for a given store.', 'woocommerce' ), + 'type' => 'string', + ), + ), + ), + 'schema' => array( $this, 'get_schema' ), + ) + ); + } + + /** + * Update the store title powered by AI. + * + * @param WP_REST_Request $request Request object. + * + * @return WP_Error|WP_REST_Response + */ + public function update_store_title( $request ) { + + $business_description = $request->get_param( 'business_description' ); + + if ( ! $business_description ) { + return new WP_Error( + 'invalid_business_description', + __( 'Invalid business description.', 'woocommerce' ) + ); + } + + $store_title = html_entity_decode( get_option( self::STORE_TITLE_OPTION_NAME, '' ) ); + $previous_ai_generated_title = html_entity_decode( get_option( self::AI_STORE_TITLE_OPTION_NAME, '' ) ); + + if ( strtolower( trim( self::DEFAULT_TITLE ) ) === strtolower( trim( $store_title ) ) || ( ! empty( $store_title ) && $previous_ai_generated_title !== $store_title ) ) { + return rest_ensure_response( array( 'ai_content_generated' => false ) ); + } + + $ai_generated_title = $this->generate_ai_title( $business_description ); + if ( is_wp_error( $ai_generated_title ) ) { + return $ai_generated_title; + } + + update_option( self::AI_STORE_TITLE_OPTION_NAME, $ai_generated_title ); + update_option( self::STORE_TITLE_OPTION_NAME, $ai_generated_title ); + + return rest_ensure_response( + array( + 'ai_content_generated' => true, + ) + ); + } + + + /** + * Generate the store title powered by AI. + * + * @param string $business_description The business description for a given store. + * + * @return string|WP_Error|WP_REST_Response The store title generated by AI. + */ + private function generate_ai_title( $business_description ) { + $ai_connection = new Connection(); + + $site_id = $ai_connection->get_site_id(); + if ( is_wp_error( $site_id ) ) { + return $site_id; + } + + $token = $ai_connection->get_jwt_token( $site_id ); + if ( is_wp_error( $token ) ) { + return $token; + } + + $prompt = "Generate a store title for a store that has the following: '$business_description'. The length of the title should be 1 and 3 words. The result should include only the store title without any other explanation, number or punctuation marks"; + + $ai_response = $ai_connection->fetch_ai_response( $token, $prompt ); + if ( is_wp_error( $ai_response ) ) { + return $ai_response; + } + + if ( ! isset( $ai_response['completion'] ) ) { + return ''; + } + + return $ai_response['completion']; + } + + /** + * Get the Business Description response. + * + * @return array + */ + public function get_schema() { + return array( + 'ai_content_generated' => true, + ); + } +} diff --git a/plugins/woocommerce/src/Admin/API/Init.php b/plugins/woocommerce/src/Admin/API/Init.php index 3bd3ec3d37b..67332f10e48 100644 --- a/plugins/woocommerce/src/Admin/API/Init.php +++ b/plugins/woocommerce/src/Admin/API/Init.php @@ -86,6 +86,14 @@ class Init { 'Automattic\WooCommerce\Admin\API\NavigationFavorites', 'Automattic\WooCommerce\Admin\API\MobileAppMagicLink', 'Automattic\WooCommerce\Admin\API\ShippingPartnerSuggestions', + 'Automattic\WooCommerce\Admin\API\AI\StoreTitle', + 'Automattic\WooCommerce\Admin\API\AI\BusinessDescription', + 'Automattic\WooCommerce\Admin\API\AI\StoreInfo', + 'Automattic\WooCommerce\Admin\API\AI\Images', + 'Automattic\WooCommerce\Admin\API\AI\Patterns', + 'Automattic\WooCommerce\Admin\API\AI\Product', + 'Automattic\WooCommerce\Admin\API\AI\Products', + 'Automattic\WooCommerce\Admin\API\Patterns', ); } diff --git a/plugins/woocommerce/src/Admin/API/LaunchYourStore.php b/plugins/woocommerce/src/Admin/API/LaunchYourStore.php index 82eefbc86a5..3bc45b296c9 100644 --- a/plugins/woocommerce/src/Admin/API/LaunchYourStore.php +++ b/plugins/woocommerce/src/Admin/API/LaunchYourStore.php @@ -133,7 +133,7 @@ class LaunchYourStore { $private_link = 'no'; $share_key = wp_generate_password( 32, false ); - add_option( 'woocommerce_coming_soon', $coming_soon ); + update_option( 'woocommerce_coming_soon', $coming_soon ); add_option( 'woocommerce_store_pages_only', $store_pages_only ); add_option( 'woocommerce_private_link', $private_link ); add_option( 'woocommerce_share_key', $share_key ); diff --git a/plugins/woocommerce/src/Admin/API/Notes.php b/plugins/woocommerce/src/Admin/API/Notes.php index d96d8165c6c..3c21865fcd9 100644 --- a/plugins/woocommerce/src/Admin/API/Notes.php +++ b/plugins/woocommerce/src/Admin/API/Notes.php @@ -538,7 +538,7 @@ class Notes extends \WC_REST_CRUD_Controller { * * @param string $url The URL needing a nonce. * @param string $action The nonce action. - * @param string $name The nonce anme. + * @param string $name The nonce name. * @return string A fully formed URL. */ private function maybe_add_nonce_to_url( string $url, string $action = '', string $name = '' ) : string { @@ -547,7 +547,7 @@ class Notes extends \WC_REST_CRUD_Controller { } if ( empty( $name ) ) { - // Default paramater name. + // Default parameter name. $name = '_wpnonce'; } diff --git a/plugins/woocommerce/src/Admin/API/OnboardingFreeExtensions.php b/plugins/woocommerce/src/Admin/API/OnboardingFreeExtensions.php index c779f9d5c5d..3d044bab301 100644 --- a/plugins/woocommerce/src/Admin/API/OnboardingFreeExtensions.php +++ b/plugins/woocommerce/src/Admin/API/OnboardingFreeExtensions.php @@ -97,37 +97,6 @@ class OnboardingFreeExtensions extends WC_REST_Data_Controller { } } - $extensions = $this->replace_jetpack_with_jetpack_boost_for_treatment( $extensions ); - return new WP_REST_Response( $extensions ); } - - private function replace_jetpack_with_jetpack_boost_for_treatment( array $extensions ) { - $is_treatment = \WooCommerce\Admin\Experimental_Abtest::in_treatment( 'woocommerce_jetpack_copy' ); - - if ( ! $is_treatment ) { - return $extensions; - } - - $has_core_profiler = array_search( 'obw/core-profiler', array_column( $extensions, 'key' ) ); - - if ( $has_core_profiler === false ) { - return $extensions; - } - - $has_jetpack = array_search( 'jetpack', array_column( $extensions[ $has_core_profiler ]['plugins'], 'key' ) ); - - if ( $has_jetpack === false ) { - return $extensions; - } - - $jetpack = &$extensions[ $has_core_profiler ]['plugins'][ $has_jetpack ]; - $jetpack->key = 'jetpack-boost'; - $jetpack->name = 'Jetpack Boost'; - $jetpack->label = __( 'Optimize store performance with Jetpack Boost', 'woocommerce' ); - $jetpack->description = __( 'Speed up your store and improve your SEO with performance-boosting tools from Jetpack. Learn more', 'woocommerce' ); - $jetpack->learn_more_link = 'https://jetpack.com/boost/'; - - return $extensions; - } } diff --git a/plugins/woocommerce/src/Admin/API/OnboardingTasks.php b/plugins/woocommerce/src/Admin/API/OnboardingTasks.php index 5e8cfe19ca6..16b7d652b7c 100644 --- a/plugins/woocommerce/src/Admin/API/OnboardingTasks.php +++ b/plugins/woocommerce/src/Admin/API/OnboardingTasks.php @@ -37,7 +37,7 @@ class OnboardingTasks extends \WC_REST_Data_Controller { protected $rest_base = 'onboarding/tasks'; /** - * Duration to milisecond mapping. + * Duration to millisecond mapping. * * @var array */ diff --git a/plugins/woocommerce/src/Admin/API/Orders.php b/plugins/woocommerce/src/Admin/API/Orders.php index 64138466a65..863c377afa4 100644 --- a/plugins/woocommerce/src/Admin/API/Orders.php +++ b/plugins/woocommerce/src/Admin/API/Orders.php @@ -239,7 +239,7 @@ class Orders extends \WC_REST_Orders_Controller { } // Format the order status. - $data['status'] = 'wc-' === substr( $data['status'], 0, 3 ) ? substr( $data['status'], 3 ) : $data['status']; + $data['status'] = OrderUtil::remove_status_prefix( $data['status'] ); // Format requested line items. $formatted_line_items = array(); diff --git a/plugins/woocommerce/src/Admin/API/Patterns.php b/plugins/woocommerce/src/Admin/API/Patterns.php new file mode 100644 index 00000000000..a4a21dc3596 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/Patterns.php @@ -0,0 +1,93 @@ + \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_pattern' ), + 'permission_callback' => function () { + return is_user_logged_in(); + }, + ), + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'update_patterns' ), + 'permission_callback' => function () { + return is_user_logged_in(); + }, + ), + ) + ); + } + + /** + * Fetch a single pattern from the PTK to ensure the API is available. + * + * @return WP_Error|WP_REST_Response + */ + public function get_pattern() { + $ptk_client = Package::container()->get( PTKClient::class ); + + $response = $ptk_client->fetch_patterns( + array( + 'per_page' => 1, + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + return rest_ensure_response( + array( + 'success' => true, + ) + ); + } + + /** + * Fetch the patterns from the PTK and update the transient. + * + * @return WP_REST_Response + */ + public function update_patterns() { + $ptk_patterns_store = Package::container()->get( PTKPatternsStore::class ); + + $ptk_patterns_store->fetch_patterns(); + + $block_patterns = Package::container()->get( BlockPatterns::class ); + + $block_patterns->register_ptk_patterns(); + + return rest_ensure_response( + array( + 'success' => true, + ) + ); + } +} diff --git a/plugins/woocommerce/src/Admin/API/PaymentGatewaySuggestions.php b/plugins/woocommerce/src/Admin/API/PaymentGatewaySuggestions.php index 09e23aa22ee..4fea841cdca 100644 --- a/plugins/woocommerce/src/Admin/API/PaymentGatewaySuggestions.php +++ b/plugins/woocommerce/src/Admin/API/PaymentGatewaySuggestions.php @@ -2,7 +2,7 @@ /** * REST API Payment Gateway Suggestions Controller * - * Handles requests to install and activate depedent plugins. + * Handles requests to install and activate dependent plugins. */ namespace Automattic\WooCommerce\Admin\API; diff --git a/plugins/woocommerce/src/Admin/API/Plugins.php b/plugins/woocommerce/src/Admin/API/Plugins.php index 746deb7f84e..f8b3e43150f 100644 --- a/plugins/woocommerce/src/Admin/API/Plugins.php +++ b/plugins/woocommerce/src/Admin/API/Plugins.php @@ -2,7 +2,7 @@ /** * REST API Plugins Controller * - * Handles requests to install and activate depedent plugins. + * Handles requests to install and activate dependent plugins. */ namespace Automattic\WooCommerce\Admin\API; diff --git a/plugins/woocommerce/src/Admin/API/ProductsLowInStock.php b/plugins/woocommerce/src/Admin/API/ProductsLowInStock.php index 223df3303cc..9e8ed8b9556 100644 --- a/plugins/woocommerce/src/Admin/API/ProductsLowInStock.php +++ b/plugins/woocommerce/src/Admin/API/ProductsLowInStock.php @@ -263,7 +263,7 @@ final class ProductsLowInStock extends \WC_REST_Products_Controller { /** * Return a query string for low in stock products. - * The query string incldues the following replacement strings: + * The query string includes the following replacement strings: * - :selects * - :postmeta_join * - :postmeta_wheres diff --git a/plugins/woocommerce/src/Admin/API/Reports/Categories/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Categories/Controller.php index bdfe5a78ca5..463ed1e3482 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Categories/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Categories/Controller.php @@ -9,16 +9,20 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Categories; defined( 'ABSPATH' ) || exit; -use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; +use Automattic\WooCommerce\Admin\API\Reports\GenericController; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; +use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * REST API Reports categories controller class. * * @internal - * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller + * @extends \Automattic\WooCommerce\Admin\API\Reports\GenericController */ -class Controller extends ReportsController implements ExportableInterface { +class Controller extends GenericController implements ExportableInterface { + + use OrderAwareControllerTrait; /** * Route base. @@ -27,6 +31,19 @@ class Controller extends ReportsController implements ExportableInterface { */ protected $rest_base = 'reports/categories'; + /** + * Get data from `'categories'` GenericQuery. + * + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. + */ + protected function get_datastore_data( $query_args = array() ) { + $query = new GenericQuery( $query_args, 'categories' ); + return $query->get_data(); + } + /** * Maps query arguments from the REST request. * @@ -52,56 +69,15 @@ class Controller extends ReportsController implements ExportableInterface { } /** - * Get all reports. + * Prepare a report data item for serialization. * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error - */ - public function get_items( $request ) { - $query_args = $this->prepare_reports_query( $request ); - $categories_query = new Query( $query_args ); - $report_data = $categories_query->get_data(); - - if ( is_wp_error( $report_data ) ) { - return $report_data; - } - - if ( ! isset( $report_data->data ) || ! isset( $report_data->page_no ) || ! isset( $report_data->pages ) ) { - return new \WP_Error( 'woocommerce_rest_reports_categories_invalid_response', __( 'Invalid response from data store.', 'woocommerce' ), array( 'status' => 500 ) ); - } - - $out_data = array(); - - foreach ( $report_data->data as $datum ) { - $item = $this->prepare_item_for_response( $datum, $request ); - $out_data[] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $out_data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); - } - - /** - * Prepare a report object for serialization. - * - * @param stdClass $report Report data. - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response + * @param mixed $report Report data item as returned from Data Store. + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { - $data = $report; - - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $data = $this->add_additional_fields_to_object( $data, $request ); - $data = $this->filter_response_by_context( $data, $context ); - // Wrap the data in a response object. - $response = rest_ensure_response( $data ); + $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** @@ -119,7 +95,7 @@ class Controller extends ReportsController implements ExportableInterface { /** * Prepare links for the request. * - * @param \Automattic\WooCommerce\Admin\API\Reports\Query $object Object data. + * @param \Automattic\WooCommerce\Admin\API\Reports\GenericQuery $object Object data. * @return array */ protected function prepare_links( $object ) { @@ -193,59 +169,17 @@ class Controller extends ReportsController implements ExportableInterface { * @return array */ public function get_collection_params() { - $params = array(); - $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); - $params['page'] = array( - 'description' => __( 'Current page of the collection.', 'woocommerce' ), - 'type' => 'integer', - 'default' => 1, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - 'minimum' => 1, + $params = parent::get_collection_params(); + $params['orderby']['default'] = 'category_id'; + $params['orderby']['enum'] = array( + 'category_id', + 'items_sold', + 'net_revenue', + 'orders_count', + 'products_count', + 'category', ); - $params['per_page'] = array( - 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ), - 'type' => 'integer', - 'default' => 10, - 'minimum' => 1, - 'maximum' => 100, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['after'] = array( - 'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ), - 'type' => 'string', - 'format' => 'date-time', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['before'] = array( - 'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ), - 'type' => 'string', - 'format' => 'date-time', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['order'] = array( - 'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ), - 'type' => 'string', - 'default' => 'desc', - 'enum' => array( 'asc', 'desc' ), - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['orderby'] = array( - 'description' => __( 'Sort collection by object attribute.', 'woocommerce' ), - 'type' => 'string', - 'default' => 'category_id', - 'enum' => array( - 'category_id', - 'items_sold', - 'net_revenue', - 'orders_count', - 'products_count', - 'category', - ), - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['interval'] = array( + $params['interval'] = array( 'description' => __( 'Time interval to use for buckets in the returned data.', 'woocommerce' ), 'type' => 'string', 'default' => 'week', @@ -259,7 +193,7 @@ class Controller extends ReportsController implements ExportableInterface { ), 'validate_callback' => 'rest_validate_request_arg', ); - $params['status_is'] = array( + $params['status_is'] = array( 'description' => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', @@ -269,7 +203,7 @@ class Controller extends ReportsController implements ExportableInterface { 'type' => 'string', ), ); - $params['status_is_not'] = array( + $params['status_is_not'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', @@ -279,7 +213,7 @@ class Controller extends ReportsController implements ExportableInterface { 'type' => 'string', ), ); - $params['categories'] = array( + $params['categories'] = array( 'description' => __( 'Limit result set to all items that have the specified term assigned in the categories taxonomy.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', @@ -288,19 +222,13 @@ class Controller extends ReportsController implements ExportableInterface { 'type' => 'integer', ), ); - $params['extended_info'] = array( + $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each category to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); - $params['force_cache_refresh'] = array( - 'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ), - 'type' => 'boolean', - 'sanitize_callback' => 'wp_validate_boolean', - 'validate_callback' => 'rest_validate_request_arg', - ); return $params; } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Categories/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Categories/DataStore.php index 36a410807e8..1eac07cc3ea 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Categories/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Categories/DataStore.php @@ -9,7 +9,6 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; -use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** @@ -20,6 +19,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * + * @override ReportsDataStore::$table_name + * * @var string */ protected static $table_name = 'wc_order_product_lookup'; @@ -27,6 +28,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override ReportsDataStore::$cache_key + * * @var string */ protected $cache_key = 'categories'; @@ -48,6 +51,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * + * @override ReportsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -61,12 +66,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override ReportsDataStore::$context + * * @var string */ protected $context = 'categories'; /** * Assign report columns once full table name has been assigned. + * + * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); @@ -145,6 +154,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Maps ordering specified by the user to columns in the database/fields in the data. * + * @override ReportsDataStore::normalize_order_by() + * * @param string $order_by Sorting criterion. * @return string */ @@ -201,104 +212,99 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. + * @override ReportsDataStore::get_default_query_vars() + * + * @return array Query parameters. */ - public function get_data( $query_args ) { + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['category_includes'] = array(); + $defaults['extended_info'] = false; + + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @see get_data + * @override ReportsDataStore::get_noncached_data() + * + * @param array $query_args Query parameters. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_data( $query_args ) { global $wpdb; $table_name = self::get_db_table_name(); + $this->initialize_queries(); - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'date', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'fields' => '*', - 'category_includes' => array(), - 'extended_info' => false, + $data = (object) array( + 'data' => array(), + 'total' => 0, + 'pages' => 0, + 'page_no' => 0, ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); + $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); + $included_categories = $this->get_included_categories_array( $query_args ); + $this->add_sql_query_params( $query_args ); - if ( false === $data ) { - $this->initialize_queries(); + if ( count( $included_categories ) > 0 ) { + $fields = $this->get_fields( $query_args ); + $ids_table = $this->get_ids_table( $included_categories, 'category_id' ); - $data = (object) array( - 'data' => array(), - 'total' => 0, - 'pages' => 0, - 'page_no' => 0, + $this->add_sql_clause( 'select', $this->format_join_selections( array_merge( array( 'category_id' ), $fields ), array( 'category_id' ) ) ); + $this->add_sql_clause( 'from', '(' ); + $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); + $this->add_sql_clause( 'from', ") AS {$table_name}" ); + $this->add_sql_clause( + 'right_join', + "RIGHT JOIN ( {$ids_table} ) AS default_results + ON default_results.category_id = {$table_name}.category_id" ); - $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); - $included_categories = $this->get_included_categories_array( $query_args ); - $this->add_sql_query_params( $query_args ); - - if ( count( $included_categories ) > 0 ) { - $fields = $this->get_fields( $query_args ); - $ids_table = $this->get_ids_table( $included_categories, 'category_id' ); - - $this->add_sql_clause( 'select', $this->format_join_selections( array_merge( array( 'category_id' ), $fields ), array( 'category_id' ) ) ); - $this->add_sql_clause( 'from', '(' ); - $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); - $this->add_sql_clause( 'from', ") AS {$table_name}" ); - $this->add_sql_clause( - 'right_join', - "RIGHT JOIN ( {$ids_table} ) AS default_results - ON default_results.category_id = {$table_name}.category_id" - ); - - $categories_query = $this->get_query_statement(); - } else { - $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $categories_query = $this->subquery->get_query_statement(); - } - $categories_data = $wpdb->get_results( - $categories_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - ARRAY_A - ); - - if ( null === $categories_data ) { - return new \WP_Error( 'woocommerce_analytics_categories_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ), array( 'status' => 500 ) ); - } - - $record_count = count( $categories_data ); - $total_pages = (int) ceil( $record_count / $query_args['per_page'] ); - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return $data; - } - - $categories_data = $this->page_records( $categories_data, $query_args['page'], $query_args['per_page'] ); - $this->include_extended_info( $categories_data, $query_args ); - $categories_data = array_map( array( $this, 'cast_numbers' ), $categories_data ); - $data = (object) array( - 'data' => $categories_data, - 'total' => $record_count, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - $this->set_cached_data( $cache_key, $data ); + $categories_query = $this->get_query_statement(); + } else { + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $categories_query = $this->subquery->get_query_statement(); } + $categories_data = $wpdb->get_results( + $categories_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ARRAY_A + ); + + if ( null === $categories_data ) { + return new \WP_Error( 'woocommerce_analytics_categories_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ), array( 'status' => 500 ) ); + } + + $record_count = count( $categories_data ); + $total_pages = (int) ceil( $record_count / $query_args['per_page'] ); + if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { + return $data; + } + + $categories_data = $this->page_records( $categories_data, $query_args['page'], $query_args['per_page'] ); + $this->include_extended_info( $categories_data, $query_args ); + $categories_data = array_map( array( $this, 'cast_numbers' ), $categories_data ); + $data = (object) array( + 'data' => $categories_data, + 'total' => $record_count, + 'pages' => $total_pages, + 'page_no' => (int) $query_args['page'], + ); return $data; } /** * Initialize query objects. + * + * @override ReportsDataStore::initialize_queries() */ protected function initialize_queries() { global $wpdb; diff --git a/plugins/woocommerce/src/Admin/API/Reports/Categories/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Categories/Query.php index a07d41a56d9..cba0dd7ff62 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Categories/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Categories/Query.php @@ -21,7 +21,9 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** - * API\Reports\Query + * API\Reports\Categories\Query + * + * @deprecated 9.3.0 Categories\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { @@ -30,18 +32,26 @@ class Query extends ReportsQuery { /** * Valid fields for Categories report. * + * @deprecated 9.3.0 Categories\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array(); } /** * Get categories data based on the current query vars. * + * @deprecated 9.3.0 Categories\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_categories_query_args', $this->get_query_vars() ); $results = \WC_Data_Store::load( self::REPORT_NAME )->get_data( $args ); return apply_filters( 'woocommerce_analytics_categories_select_query', $results, $args ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Controller.php index 078d1cc15a9..6e641575c4d 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Controller.php @@ -1,8 +1,6 @@ is_valid_order( $order ) ) { - return null; - } - - if ( 'shop_order_refund' === $order->get_type() ) { - $order = wc_get_order( $order->get_parent_id() ); - - // If the parent order doesn't exist, return null. - if ( ! $this->is_valid_order( $order ) ) { - return null; - } - } - - if ( ! has_filter( 'woocommerce_order_number' ) ) { - return $order->get_id(); - } - - return $order->get_order_number(); - } - - /** - * Whether the order is valid. - * - * @param bool|WC_Order|WC_Order_Refund $order Order object. - * @return bool True if the order is valid, false otherwise. - */ - protected function is_valid_order( $order ) { - return $order instanceof \WC_Order || $order instanceof \WC_Order_Refund; - } - - /** - * Get the order total with the related currency formatting. - * Returns the parent order total if the order is actually a refund. - * - * @param int $order_id Order ID. - * @return string|null The Order Number or null if the order doesn't exist. - */ - protected function get_total_formatted( $order_id ) { - $order = wc_get_order( $order_id ); - - if ( ! $this->is_valid_order( $order ) ) { - return null; - } - - if ( 'shop_order_refund' === $order->get_type() ) { - $order = wc_get_order( $order->get_parent_id() ); - - if ( ! $this->is_valid_order( $order ) ) { - return null; - } - } - - return wp_strip_all_tags( html_entity_decode( $order->get_formatted_order_total() ), true ); - } - /** * Prepare a report object for serialization. * @@ -214,12 +152,8 @@ class Controller extends GenericController { 'path' => $report->path, ); - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $data = $this->add_additional_fields_to_object( $data, $request ); - $data = $this->filter_response_by_context( $data, $context ); - // Wrap the data in a response object. - $response = rest_ensure_response( $data ); + $response = parent::prepare_item_for_response( $data, $request ); $response->add_links( array( 'self' => array( @@ -249,6 +183,8 @@ class Controller extends GenericController { /** * Get the Report's schema, conforming to JSON Schema. * + * @override WP_REST_Controller::get_item_schema() + * * @return array */ public function get_item_schema() { @@ -291,42 +227,4 @@ class Controller extends GenericController { 'context' => $this->get_context_param( array( 'default' => 'view' ) ), ); } - - /** - * Get order statuses without prefixes. - * Includes unregistered statuses that have been marked "actionable". - * - * @internal - * @return array - */ - public static function get_order_statuses() { - // Allow all statuses selected as "actionable" - this may include unregistered statuses. - // See: https://github.com/woocommerce/woocommerce-admin/issues/5592. - $actionable_statuses = get_option( 'woocommerce_actionable_order_statuses', array() ); - - // See WC_REST_Orders_V2_Controller::get_collection_params() re: any/trash statuses. - $registered_statuses = array_merge( array( 'any', 'trash' ), array_keys( self::get_order_status_labels() ) ); - - // Merge the status arrays (using flip to avoid array_unique()). - $allowed_statuses = array_keys( array_merge( array_flip( $registered_statuses ), array_flip( $actionable_statuses ) ) ); - - return $allowed_statuses; - } - - /** - * Get order statuses (and labels) without prefixes. - * - * @internal - * @return array - */ - public static function get_order_status_labels() { - $order_statuses = array(); - - foreach ( wc_get_order_statuses() as $key => $label ) { - $new_key = str_replace( 'wc-', '', $key ); - $order_statuses[ $new_key ] = $label; - } - - return $order_statuses; - } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Coupons/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Coupons/Controller.php index 5a884f23bb7..d7f1fd5d818 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Coupons/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Coupons/Controller.php @@ -11,6 +11,7 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; @@ -29,6 +30,19 @@ class Controller extends GenericController implements ExportableInterface { */ protected $rest_base = 'reports/coupons'; + /** + * Get data from `'coupons'` GenericQuery. + * + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. + */ + protected function get_datastore_data( $query_args = array() ) { + $query = new GenericQuery( $query_args, 'coupons' ); + return $query->get_data(); + } + /** * Maps query arguments from the REST request. * @@ -50,38 +64,11 @@ class Controller extends GenericController implements ExportableInterface { } /** - * Get all reports. + * Prepare a report data item for serialization. * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error - */ - public function get_items( $request ) { - $query_args = $this->prepare_reports_query( $request ); - $coupons_query = new Query( $query_args ); - $report_data = $coupons_query->get_data(); - - $data = array(); - - foreach ( $report_data->data as $coupons_data ) { - $item = $this->prepare_item_for_response( $coupons_data, $request ); - $data[] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); - } - - /** - * Prepare a report object for serialization. - * - * @param array $report Report data. - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response + * @param array $report Report data item as returned from Data Store. + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Coupons/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Coupons/DataStore.php index 40aefe535c7..150c99264da 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Coupons/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Coupons/DataStore.php @@ -21,6 +21,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * + * @override ReportsDataStore::$table_name + * * @var string */ protected static $table_name = 'wc_order_coupon_lookup'; @@ -28,6 +30,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override ReportsDataStore::$cache_key + * * @var string */ protected $cache_key = 'coupons'; @@ -35,6 +39,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * + * @override ReportsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -46,12 +52,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override ReportsDataStore::$context + * * @var string */ protected $context = 'coupons'; /** * Assign report columns once full table name has been assigned. + * + * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); @@ -148,6 +158,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Maps ordering specified by the user to columns in the database/fields in the data. * + * @override ReportsDataStore::normalize_order_by() + * * @param string $order_by Sorting criterion. * @return string */ @@ -223,119 +235,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } } - /** - * Returns the report data based on parameters supplied by the user. - * - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. - */ - public function get_data( $query_args ) { - global $wpdb; - - $table_name = self::get_db_table_name(); - - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'coupon_id', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'fields' => '*', - 'coupons' => array(), - 'extended_info' => false, - ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); - - if ( false === $data ) { - $this->initialize_queries(); - - $data = (object) array( - 'data' => array(), - 'total' => 0, - 'pages' => 0, - 'page_no' => 0, - ); - - $selections = $this->selected_columns( $query_args ); - $included_coupons = $this->get_included_coupons_array( $query_args ); - $limit_params = $this->get_limit_params( $query_args ); - $this->subquery->add_sql_clause( 'select', $selections ); - $this->add_sql_query_params( $query_args ); - - if ( count( $included_coupons ) > 0 ) { - $total_results = count( $included_coupons ); - $total_pages = (int) ceil( $total_results / $limit_params['per_page'] ); - - $fields = $this->get_fields( $query_args ); - $ids_table = $this->get_ids_table( $included_coupons, 'coupon_id' ); - - $this->add_sql_clause( 'select', $this->format_join_selections( $fields, array( 'coupon_id' ) ) ); - $this->add_sql_clause( 'from', '(' ); - $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); - $this->add_sql_clause( 'from', ") AS {$table_name}" ); - $this->add_sql_clause( - 'right_join', - "RIGHT JOIN ( {$ids_table} ) AS default_results - ON default_results.coupon_id = {$table_name}.coupon_id" - ); - - $coupons_query = $this->get_query_statement(); - } else { - $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $coupons_query = $this->subquery->get_query_statement(); - - $this->subquery->clear_sql_clause( array( 'select', 'order_by', 'limit' ) ); - $this->subquery->add_sql_clause( 'select', 'coupon_id' ); - $coupon_subquery = "SELECT COUNT(*) FROM ( - {$this->subquery->get_query_statement()} - ) AS tt"; - - $db_records_count = (int) $wpdb->get_var( - $coupon_subquery // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - ); - - $total_results = $db_records_count; - $total_pages = (int) ceil( $db_records_count / $limit_params['per_page'] ); - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return $data; - } - } - - $coupon_data = $wpdb->get_results( - $coupons_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - ARRAY_A - ); - if ( null === $coupon_data ) { - return $data; - } - - $this->include_extended_info( $coupon_data, $query_args ); - - $coupon_data = array_map( array( $this, 'cast_numbers' ), $coupon_data ); - $data = (object) array( - 'data' => $coupon_data, - 'total' => $total_results, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - $this->set_cached_data( $cache_key, $data ); - } - - return $data; - } - /** * Get coupon ID for an order. * @@ -363,6 +262,115 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { return wc_get_coupon_id_by_code( $coupon_item->get_code() ); } + /** + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. + * + * @override ReportsDataStore::get_default_query_vars() + * + * @return array Query parameters. + */ + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['orderby'] = 'coupon_id'; + $defaults['coupons'] = array(); + $defaults['extended_info'] = false; + + return $defaults; + } + + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override ReportsDataStore::get_noncached_data() + * + * @see get_data + * @param array $query_args Query parameters. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_data( $query_args ) { + global $wpdb; + + $table_name = self::get_db_table_name(); + + $this->initialize_queries(); + + $data = (object) array( + 'data' => array(), + 'total' => 0, + 'pages' => 0, + 'page_no' => 0, + ); + + $selections = $this->selected_columns( $query_args ); + $included_coupons = $this->get_included_coupons_array( $query_args ); + $limit_params = $this->get_limit_params( $query_args ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->add_sql_query_params( $query_args ); + + if ( count( $included_coupons ) > 0 ) { + $total_results = count( $included_coupons ); + $total_pages = (int) ceil( $total_results / $limit_params['per_page'] ); + + $fields = $this->get_fields( $query_args ); + $ids_table = $this->get_ids_table( $included_coupons, 'coupon_id' ); + + $this->add_sql_clause( 'select', $this->format_join_selections( $fields, array( 'coupon_id' ) ) ); + $this->add_sql_clause( 'from', '(' ); + $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); + $this->add_sql_clause( 'from', ") AS {$table_name}" ); + $this->add_sql_clause( + 'right_join', + "RIGHT JOIN ( {$ids_table} ) AS default_results + ON default_results.coupon_id = {$table_name}.coupon_id" + ); + + $coupons_query = $this->get_query_statement(); + } else { + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $coupons_query = $this->subquery->get_query_statement(); + + $this->subquery->clear_sql_clause( array( 'select', 'order_by', 'limit' ) ); + $this->subquery->add_sql_clause( 'select', 'coupon_id' ); + $coupon_subquery = "SELECT COUNT(*) FROM ( + {$this->subquery->get_query_statement()} + ) AS tt"; + + $db_records_count = (int) $wpdb->get_var( + $coupon_subquery // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ); + + $total_results = $db_records_count; + $total_pages = (int) ceil( $db_records_count / $limit_params['per_page'] ); + if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { + return $data; + } + } + + $coupon_data = $wpdb->get_results( + $coupons_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ARRAY_A + ); + if ( null === $coupon_data ) { + return $data; + } + + $this->include_extended_info( $coupon_data, $query_args ); + + $coupon_data = array_map( array( $this, 'cast_numbers' ), $coupon_data ); + $data = (object) array( + 'data' => $coupon_data, + 'total' => $total_results, + 'pages' => $total_pages, + 'page_no' => (int) $query_args['page'], + ); + + return $data; + } + /** * Create or update an an entry in the wc_order_coupon_lookup table for an order. * diff --git a/plugins/woocommerce/src/Admin/API/Reports/Coupons/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Coupons/Query.php index f81fa4b8182..690861ae4d4 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Coupons/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Coupons/Query.php @@ -21,24 +21,34 @@ use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Coupons\Query + * + * @deprecated 9.3.0 Coupons\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * + * @deprecated 9.3.0 Coupons\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array(); } /** * Get product data based on the current query vars. * + * @deprecated 9.3.0 Coupons\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_coupons_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-coupons' ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/Controller.php index 88e7c2109b1..593b3c28915 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/Controller.php @@ -10,7 +10,7 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; -use Automattic\WooCommerce\Admin\API\Reports\ParameterException; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; @@ -54,51 +54,30 @@ class Controller extends GenericStatsController { } /** - * Get all reports. + * Get data from `'coupons-stats'` GenericQuery. * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. */ - public function get_items( $request ) { - $query_args = $this->prepare_reports_query( $request ); - $coupons_query = new Query( $query_args ); - try { - $report_data = $coupons_query->get_data(); - } catch ( ParameterException $e ) { - return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); - } - - $out_data = array( - 'totals' => get_object_vars( $report_data->totals ), - 'intervals' => array(), - ); - - foreach ( $report_data->intervals as $interval_data ) { - $item = $this->prepare_item_for_response( (object) $interval_data, $request ); - $out_data['intervals'][] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $out_data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); + protected function get_datastore_data( $query_args = array() ) { + $query = new GenericQuery( $query_args, 'coupons-stats' ); + return $query->get_data(); } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param stdClass $report Report data. + * @param mixed $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { - $data = get_object_vars( $report ); - - $response = parent::prepare_item_for_response( $data, $request ); + $response = parent::prepare_item_for_response( $report, $request ); + // Map to `object` for backwards compatibility. + $report = (object) $report; /** * Filter a report returned from the API. * @@ -189,15 +168,6 @@ class Controller extends GenericStatsController { ), 'validate_callback' => 'rest_validate_request_arg', ); - $params['fields'] = array( - 'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ), - 'type' => 'array', - 'sanitize_callback' => 'wp_parse_slug_list', - 'validate_callback' => 'rest_validate_request_arg', - 'items' => array( - 'type' => 'string', - ), - ); return $params; } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/DataStore.php index 459917ec87d..f8ab7562e40 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/DataStore.php @@ -9,15 +9,19 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; -use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; +use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Coupons\Stats\DataStore. */ class DataStore extends CouponsDataStore implements DataStoreInterface { + use StatsDataStoreTrait; + /** * Mapping columns to data type to return correct response types. * + * @override CouponsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -33,6 +37,8 @@ class DataStore extends CouponsDataStore implements DataStoreInterface { /** * SQL columns to select in the db query. * + * @override CouponsDataStore::$report_columns + * * @var array */ protected $report_columns; @@ -40,6 +46,8 @@ class DataStore extends CouponsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override CouponsDataStore::$context + * * @var string */ protected $context = 'coupons_stats'; @@ -47,12 +55,16 @@ class DataStore extends CouponsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override CouponsDataStore::get_default_query_vars() + * * @var string */ protected $cache_key = 'coupons_stats'; /** * Assign report columns once full table name has been assigned. + * + * @override CouponsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); @@ -105,145 +117,114 @@ class DataStore extends CouponsDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @since 3.5.0 - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. + * @override CouponsDataStore::get_default_query_vars() + * + * @return array Query parameters. */ - public function get_data( $query_args ) { + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['coupons'] = array(); + $defaults['interval'] = 'week'; + + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override CouponsDataStore::get_noncached_stats_data() + * + * @see get_data + * @see get_noncached_stats_data + * @param array $query_args Query parameters. + * @param array $params Query limit parameters. + * @param stdClass $data Reference to the data object to fill. + * @param int $expected_interval_count Number of expected intervals. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'date', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'fields' => '*', - 'interval' => 'week', - 'coupons' => array(), + $this->initialize_queries(); + + $selections = $this->selected_columns( $query_args ); + $totals_query = array(); + $intervals_query = array(); + $limit_params = $this->get_limit_sql_params( $query_args ); + $this->update_sql_query_params( $query_args, $totals_query, $intervals_query ); + + $db_intervals = $wpdb->get_col( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->interval_query->get_query_statement() ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); + $db_interval_count = count( $db_intervals ); - if ( false === $data ) { - $this->initialize_queries(); + $this->total_query->add_sql_clause( 'select', $selections ); + $totals = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->total_query->get_query_statement(), + ARRAY_A + ); - $data = (object) array( - 'data' => array(), - 'total' => 0, - 'pages' => 0, - 'page_no' => 0, - ); - - $selections = $this->selected_columns( $query_args ); - $totals_query = array(); - $intervals_query = array(); - $limit_params = $this->get_limit_sql_params( $query_args ); - $this->update_sql_query_params( $query_args, $totals_query, $intervals_query ); - - $db_intervals = $wpdb->get_col( - $this->interval_query->get_query_statement() - ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - - $db_interval_count = count( $db_intervals ); - $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); - $total_pages = (int) ceil( $expected_interval_count / $limit_params['per_page'] ); - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return $data; - } - - $this->total_query->add_sql_clause( 'select', $selections ); - $totals = $wpdb->get_results( - $this->total_query->get_query_statement(), - ARRAY_A - ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - - if ( null === $totals ) { - return $data; - } - - // @todo remove these assignements when refactoring segmenter classes to use query objects. - $totals_query = array( - 'from_clause' => $this->total_query->get_sql_clause( 'join' ), - 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), - 'where_clause' => $this->total_query->get_sql_clause( 'where' ), - ); - $intervals_query = array( - 'select_clause' => $this->get_sql_clause( 'select' ), - 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), - 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), - 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), - 'limit' => $this->get_sql_clause( 'limit' ), - ); - $segmenter = new Segmenter( $query_args, $this->report_columns ); - $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); - $totals = (object) $this->cast_numbers( $totals[0] ); - - // Intervals. - $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); - $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); - - if ( '' !== $selections ) { - $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); - } - - $intervals = $wpdb->get_results( - $this->interval_query->get_query_statement(), - ARRAY_A - ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - - if ( null === $intervals ) { - return $data; - } - - $data = (object) array( - 'totals' => $totals, - 'intervals' => $intervals, - 'total' => $expected_interval_count, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $limit_params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { - $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); - $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); - $this->remove_extra_records( $data, $query_args['page'], $limit_params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); - } else { - $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); - } - $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); - $this->create_interval_subtotals( $data->intervals ); - - $this->set_cached_data( $cache_key, $data ); + if ( null === $totals ) { + return $data; } + // phpcs:ignore Generic.Commenting.Todo.TaskFound + // @todo remove these assignements when refactoring segmenter classes to use query objects. + $totals_query = array( + 'from_clause' => $this->total_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->total_query->get_sql_clause( 'where' ), + ); + $intervals_query = array( + 'select_clause' => $this->get_sql_clause( 'select' ), + 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), + 'limit' => $this->get_sql_clause( 'limit' ), + ); + $segmenter = new Segmenter( $query_args, $this->report_columns ); + $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); + $totals = (object) $this->cast_numbers( $totals[0] ); + + // Intervals. + $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); + + if ( '' !== $selections ) { + $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); + } + + $intervals = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->interval_query->get_query_statement(), + ARRAY_A + ); + + if ( null === $intervals ) { + return $data; + } + + $data->totals = $totals; + $data->intervals = $intervals; + + if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $limit_params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); + $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); + $this->remove_extra_records( $data, $query_args['page'], $limit_params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + } else { + $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); + } + $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); + return $data; } - - /** - * Initialize query objects. - */ - protected function initialize_queries() { - $this->clear_all_clauses(); - unset( $this->subquery ); - $this->total_query = new SqlQuery( $this->context . '_total' ); - $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); - - $this->interval_query = new SqlQuery( $this->context . '_interval' ); - $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); - $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); - } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/Query.php index 3e538cee9e3..0defa18ad41 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Coupons/Stats/Query.php @@ -21,24 +21,34 @@ use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Coupons\Stats\Query + * + * @deprecated 9.3.0 Coupons\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * + * @deprecated 9.3.0 Coupons\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array(); } /** * Get product data based on the current query vars. * + * @deprecated 9.3.0 Coupons\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_coupons_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-coupons-stats' ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php index 6502d95e2c6..003fc089b0d 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php @@ -33,6 +33,19 @@ class Controller extends GenericController implements ExportableInterface { */ protected $rest_base = 'reports/customers'; + /** + * Get data from Customers\Query. + * + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. + */ + protected function get_datastore_data( $query_args = array() ) { + $query = new Query( $query_args ); + return $query->get_data(); + } + /** * Maps query arguments from the REST request. * @@ -84,34 +97,6 @@ class Controller extends GenericController implements ExportableInterface { return $args; } - /** - * Get all reports. - * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error - */ - public function get_items( $request ) { - $query_args = $this->prepare_reports_query( $request ); - $customers_query = new Query( $query_args ); - $report_data = $customers_query->get_data(); - - $data = array(); - - foreach ( $report_data->data as $customer_data ) { - $item = $this->prepare_item_for_response( $customer_data, $request ); - $data[] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); - } - - /** * Get one report. * @@ -139,11 +124,11 @@ class Controller extends GenericController implements ExportableInterface { } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param array $report Report data. - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response + * @param array $report Report data item as returned from Data Store. + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php index 74f1a19e7cd..fac1fdaa961 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php @@ -22,6 +22,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * + * @override ReportsDataStore::$table_name + * * @var string */ protected static $table_name = 'wc_customer_lookup'; @@ -29,6 +31,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override ReportsDataStore::$cache_key + * * @var string */ protected $cache_key = 'customers'; @@ -36,6 +40,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * + * @override ReportsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -49,12 +55,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override ReportsDataStore::$context + * * @var string */ protected $context = 'customers'; /** * Assign report columns once full table name has been assigned. + * + * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { global $wpdb; @@ -168,6 +178,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Maps ordering specified by the user to columns in the database/fields in the data. * + * @override ReportsDataStore::normalize_order_by() + * * @param string $order_by Sorting criterion. * @return string */ @@ -182,6 +194,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Fills WHERE clause of SQL request with date-related constraints. * + * @override ReportsDataStore::add_time_period_sql_params() + * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. */ @@ -409,89 +423,20 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. + * @override ReportsDataStore::get_default_query_vars() + * + * @return array Query parameters. */ - public function get_data( $query_args ) { - global $wpdb; + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['orderby'] = 'date_registered'; + $defaults['order_before'] = TimeInterval::default_before(); + $defaults['order_after'] = TimeInterval::default_after(); - $customers_table_name = self::get_db_table_name(); - $order_stats_table_name = $wpdb->prefix . 'wc_order_stats'; - - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'date_registered', - 'order_before' => TimeInterval::default_before(), - 'order_after' => TimeInterval::default_after(), - 'fields' => '*', - ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); - - if ( false === $data ) { - $this->initialize_queries(); - - $data = (object) array( - 'data' => array(), - 'total' => 0, - 'pages' => 0, - 'page_no' => 0, - ); - - $selections = $this->selected_columns( $query_args ); - $sql_query_params = $this->add_sql_query_params( $query_args ); - $count_query = "SELECT COUNT(*) FROM ( - {$this->subquery->get_query_statement()} - ) as tt - "; - $db_records_count = (int) $wpdb->get_var( - $count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - ); - - $params = $this->get_limit_params( $query_args ); - $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return $data; - } - - $this->subquery->clear_sql_clause( 'select' ); - $this->subquery->add_sql_clause( 'select', $selections ); - $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - - $customer_data = $wpdb->get_results( - $this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - ARRAY_A - ); - - if ( null === $customer_data ) { - return $data; - } - - $customer_data = array_map( array( $this, 'cast_numbers' ), $customer_data ); - $data = (object) array( - 'data' => $customer_data, - 'total' => $db_records_count, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - $this->set_cached_data( $cache_key, $data ); - } - - return $data; + return $defaults; } /** @@ -533,6 +478,69 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } } + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override ReportsDataStore::get_noncached_data() + * + * @see get_data + * @param array $query_args Query parameters. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_data( $query_args ) { + global $wpdb; + + $this->initialize_queries(); + + $data = (object) array( + 'data' => array(), + 'total' => 0, + 'pages' => 0, + 'page_no' => 0, + ); + + $selections = $this->selected_columns( $query_args ); + $sql_query_params = $this->add_sql_query_params( $query_args ); + $count_query = "SELECT COUNT(*) FROM ( + {$this->subquery->get_query_statement()} + ) as tt + "; + $db_records_count = (int) $wpdb->get_var( + $count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ); + + $params = $this->get_limit_params( $query_args ); + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); + if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { + return $data; + } + + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + + $customer_data = $wpdb->get_results( + $this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ARRAY_A + ); + + if ( null === $customer_data ) { + return $data; + } + + $customer_data = array_map( array( $this, 'cast_numbers' ), $customer_data ); + $data = (object) array( + 'data' => $customer_data, + 'total' => $db_records_count, + 'pages' => $total_pages, + 'page_no' => (int) $query_args['page'], + ); + + return $data; + } + /** * Get or create a customer from a given order. * diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Query.php index c6e5c8c491d..0aa5cbe9247 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Query.php @@ -16,14 +16,23 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Customers; -defined( 'ABSPATH' ) || exit; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; -use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; +defined( 'ABSPATH' ) || exit; /** * API\Reports\Customers\Query */ -class Query extends ReportsQuery { +class Query extends GenericQuery { + + /** + * Specific query name. + * Will be used to load the `report-{name}` data store, + * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. + * + * @var string + */ + protected $name = 'customers'; /** * Valid fields for Customers report. @@ -39,17 +48,4 @@ class Query extends ReportsQuery { 'fields' => '*', ); } - - /** - * Get product data based on the current query vars. - * - * @return array - */ - public function get_data() { - $args = apply_filters( 'woocommerce_analytics_customers_query_args', $this->get_query_vars() ); - - $data_store = \WC_Data_Store::load( 'report-customers' ); - $results = $data_store->get_data( $args ); - return apply_filters( 'woocommerce_analytics_customers_select_query', $results, $args ); - } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Controller.php index 95496f7ae30..3b1b201e50f 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Controller.php @@ -7,6 +7,8 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats; +use Automattic\WooCommerce\Admin\API\Reports\Customers\Query; + defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; @@ -83,7 +85,7 @@ class Controller extends \WC_REST_Reports_Controller { */ public function get_items( $request ) { $query_args = $this->prepare_reports_query( $request ); - $customers_query = new Query( $query_args ); + $customers_query = new Query( $query_args, 'customers-stats' ); $report_data = $customers_query->get_data(); $out_data = array( 'totals' => $report_data, @@ -93,11 +95,11 @@ class Controller extends \WC_REST_Reports_Controller { } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param Array $report Report data. - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response + * @param array $report Report data item as returned from Data Store. + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $data = $report; diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/DataStore.php index a57109cfbc8..5e6798e8e2d 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/DataStore.php @@ -8,6 +8,7 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore; +use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; /** @@ -17,6 +18,8 @@ class DataStore extends CustomersDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * + * @override CustomersDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -29,6 +32,8 @@ class DataStore extends CustomersDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override CustomersDataStore::$cache_key + * * @var string */ protected $cache_key = 'customers_stats'; @@ -36,12 +41,16 @@ class DataStore extends CustomersDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override CustomersDataStore::$context + * * @var string */ protected $context = 'customers_stats'; /** * Assign report columns once full table name has been assigned. + * + * @override CustomersDataStore::assign_report_columns() */ protected function assign_report_columns() { $this->report_columns = array( @@ -53,76 +62,70 @@ class DataStore extends CustomersDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. + * @override CustomersDataStore::get_default_query_vars() + * + * @return array Query parameters. */ - public function get_data( $query_args ) { + public function get_default_query_vars() { + $defaults = ReportsDataStore::get_default_query_vars(); + $defaults['orderby'] = 'date_registered'; + // Do not set `order_before` and `order_after` here, like in the parent class. + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override CustomersDataStore::get_noncached_data() + * + * @see get_data + * @param array $query_args Query parameters. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_data( $query_args ) { global $wpdb; + $this->initialize_queries(); - $customers_table_name = self::get_db_table_name(); - - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'date_registered', - 'fields' => '*', + $data = (object) array( + 'customers_count' => 0, + 'avg_orders_count' => 0, + 'avg_total_spend' => 0.0, + 'avg_avg_order_value' => 0.0, ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); + $selections = $this->selected_columns( $query_args ); + $this->add_sql_query_params( $query_args ); + // Clear SQL clauses set for parent class queries that are different here. + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', 'SUM( total_sales ) AS total_spend,' ); + $this->subquery->add_sql_clause( + 'select', + 'SUM( CASE WHEN parent_id = 0 THEN 1 END ) as orders_count,' + ); + $this->subquery->add_sql_clause( + 'select', + 'CASE WHEN SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) = 0 THEN NULL ELSE SUM( total_sales ) / SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) END AS avg_order_value' + ); - if ( false === $data ) { - $this->initialize_queries(); + $this->clear_sql_clause( array( 'order_by', 'limit' ) ); + $this->add_sql_clause( 'select', $selections ); + $this->add_sql_clause( 'from', "({$this->subquery->get_query_statement()}) AS tt" ); - $data = (object) array( - 'customers_count' => 0, - 'avg_orders_count' => 0, - 'avg_total_spend' => 0.0, - 'avg_avg_order_value' => 0.0, - ); + $report_data = $wpdb->get_results( + $this->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ARRAY_A + ); - $selections = $this->selected_columns( $query_args ); - $this->add_sql_query_params( $query_args ); - // Clear SQL clauses set for parent class queries that are different here. - $this->subquery->clear_sql_clause( 'select' ); - $this->subquery->add_sql_clause( 'select', 'SUM( total_sales ) AS total_spend,' ); - $this->subquery->add_sql_clause( - 'select', - 'SUM( CASE WHEN parent_id = 0 THEN 1 END ) as orders_count,' - ); - $this->subquery->add_sql_clause( - 'select', - 'CASE WHEN SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) = 0 THEN NULL ELSE SUM( total_sales ) / SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) END AS avg_order_value' - ); - - $this->clear_sql_clause( array( 'order_by', 'limit' ) ); - $this->add_sql_clause( 'select', $selections ); - $this->add_sql_clause( 'from', "({$this->subquery->get_query_statement()}) AS tt" ); - - $report_data = $wpdb->get_results( - $this->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - ARRAY_A - ); - - if ( null === $report_data ) { - return $data; - } - - $data = (object) $this->cast_numbers( $report_data[0] ); - - $this->set_cached_data( $cache_key, $data ); + if ( null === $report_data ) { + return $data; } + $data = (object) $this->cast_numbers( $report_data[0] ); + return $data; } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Query.php index 291358a2cbb..d3adebde7a3 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Query.php @@ -22,15 +22,21 @@ use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Customers\Stats\Query + * + * @deprecated 9.3.0 Customers\Stats\Query class is deprecated, please use `Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Customers report. * + * @deprecated 9.3.0 Customers\Stats\Query class is deprecated, please use `Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array( 'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default. 'page' => 1, @@ -43,9 +49,13 @@ class Query extends ReportsQuery { /** * Get product data based on the current query vars. * + * @deprecated 9.3.0 Customers\Stats\Query class is deprecated, please use `Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, 'x.x.x', '`Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_customers_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-customers-stats' ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php index 50a9213d9fc..4d200ab1bf9 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php @@ -9,12 +9,59 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } +use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; /** - * Admin\API\Reports\DataStore: Common parent for custom report data stores. + * Common parent for custom report data stores. + * + * We use Report DataStores to separate DB data retrieval logic from the REST API controllers. + * + * Handles caching, data normalization, intervals-related methods, and other common functionality. + * So, in your custom report DataStore class that extends this class + * you can focus on specifics by overriding the `get_noncached_data` method. + * + * Minimalistic example: + *
class MyDataStore extends DataStore implements DataStoreInterface {
+ *     /** Cache identifier, used by the `DataStore` class to handle caching for you. */
+ *     protected $cache_key = 'my_thing';
+ *     /** Data store context used to pass to filters. */
+ *     protected $context = 'my_thing';
+ *     /** Table used to get the data. */
+ *     protected static $table_name = 'my_table';
+ *     /**
+ *      * Method that overrides the `DataStore::get_noncached_data()` to return the report data.
+ *      * Will be called by `get_data` if there is no data in cache.
+ *      */
+ *     public function get_noncached_data( $query ) {
+ *         // Do your magic.
+ *
+ *         // Then return your data in conforming object structure.
+ *         return (object) array(
+ *             'data' => $product_data,
+ *             'total' => 1,
+ *             'page_no' => 1,
+ *             'pages' => 1,
+ *         );
+ *     }
+ * }
+ * 
+ * + * Please use the `woocommerce_data_stores` filter to add your custom data store to the list of available ones. + * Then, your store could be accessed by Controller classes ({@see GenericController::get_datastore_data() GenericController::get_datastore_data()}) + * or using {@link \WC_Data_Store::load() \WC_Data_Store::load()}. + * + * We recommend registering using the REST base name of your Controller as the key, e.g.: + *
add_filter( 'woocommerce_data_stores', function( $stores ) {
+ *     $stores['reports/my-thing'] = 'MyExtension\Admin\Analytics\Rest_API\MyDataStore';
+ * } );
+ * 
+ * This way, `GenericController` will pick it up automatically. + * + * Note that this class is NOT {@link https://developer.woocommerce.com/docs/how-to-manage-woocommerce-data-stores/ a CRUD data store}. + * It does not implement the {@see WC_Object_Data_Store_Interface WC_Object_Data_Store_Interface} nor extend WC_Data & WC_Data_Store_WP classes. */ -class DataStore extends SqlQuery { +class DataStore extends SqlQuery implements DataStoreInterface { /** * Cache group for the reports. @@ -90,6 +137,8 @@ class DataStore extends SqlQuery { /** * Data store context used to pass to filters. * + * @override SqlQuery + * * @var string */ protected $context = 'reports'; @@ -138,6 +187,8 @@ class DataStore extends SqlQuery { /** * Class constructor. + * + * @override SqlQuery::__construct() */ public function __construct() { self::set_db_table_name(); @@ -160,6 +211,54 @@ class DataStore extends SqlQuery { } } + + /** + * Get the data based on args. + * + * Returns the report data based on parameters supplied by the user. + * Fetches it from cache or returns `get_noncached_data` result. + * + * @param array $query_args Query parameters. + * @return stdClass|WP_Error + */ + public function get_data( $query_args ) { + $defaults = $this->get_default_query_vars(); + $query_args = wp_parse_args( $query_args, $defaults ); + $this->normalize_timezones( $query_args, $defaults ); + + /* + * We need to get the cache key here because + * parent::update_intervals_sql_params() modifies $query_args. + */ + $cache_key = $this->get_cache_key( $query_args ); + $data = $this->get_cached_data( $cache_key ); + + if ( false === $data ) { + $data = $this->get_noncached_data( $query_args ); + $this->set_cached_data( $cache_key, $data ); + } + + return $data; + } + + /** + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. + * + * @return array Query parameters. + */ + public function get_default_query_vars() { + return array( + 'per_page' => get_option( 'posts_per_page' ), + 'page' => 1, + 'order' => 'DESC', + 'orderby' => 'date', + 'before' => TimeInterval::default_before(), + 'after' => TimeInterval::default_after(), + 'fields' => '*', + ); + } + /** * Get table name from database class. */ @@ -168,6 +267,19 @@ class DataStore extends SqlQuery { return isset( $wpdb->{static::$table_name} ) ? $wpdb->{static::$table_name} : $wpdb->prefix . static::$table_name; } + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @see get_data + * @param array $query_args Query parameters. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_data( $query_args ) { + /* translators: %s: Method name */ + return new \WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass.", 'woocommerce' ), __METHOD__ ), array( 'status' => 405 ) ); + } + /** * Set table name from database class. */ diff --git a/plugins/woocommerce/src/Admin/API/Reports/Downloads/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Downloads/Controller.php index 5978e7cdc0e..1330e2ad4e1 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Downloads/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Downloads/Controller.php @@ -9,16 +9,20 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads; defined( 'ABSPATH' ) || exit; -use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; +use Automattic\WooCommerce\Admin\API\Reports\GenericController; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; +use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * REST API Reports downloads controller class. * * @internal - * @extends Automattic\WooCommerce\Admin\API\Reports\Controller + * @extends Automattic\WooCommerce\Admin\API\Reports\GenericController */ -class Controller extends ReportsController implements ExportableInterface { +class Controller extends GenericController implements ExportableInterface { + + use OrderAwareControllerTrait; /** * Route base. @@ -28,67 +32,40 @@ class Controller extends ReportsController implements ExportableInterface { protected $rest_base = 'reports/downloads'; /** - * Get items. + * Get data from `'downloads'` GenericQuery. * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. */ - public function get_items( $request ) { - $args = array(); - $registered = array_keys( $this->get_collection_params() ); - foreach ( $registered as $param_name ) { - if ( isset( $request[ $param_name ] ) ) { - $args[ $param_name ] = $request[ $param_name ]; - } - } - - $reports = new Query( $args ); - $downloads_data = $reports->get_data(); - - $data = array(); - - foreach ( $downloads_data->data as $download_data ) { - $item = $this->prepare_item_for_response( $download_data, $request ); - $data[] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $data, - (int) $downloads_data->total, - (int) $downloads_data->page_no, - (int) $downloads_data->pages - ); + protected function get_datastore_data( $query_args = array() ) { + $query = new GenericQuery( $query_args, 'downloads' ); + return $query->get_data(); } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param Array $report Report data. + * @param Array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { - $data = $report; - - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $data = $this->add_additional_fields_to_object( $data, $request ); - $data = $this->filter_response_by_context( $data, $context ); - // Wrap the data in a response object. - $response = rest_ensure_response( $data ); + $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); - $response->data['date'] = get_date_from_gmt( $data['date_gmt'], 'Y-m-d H:i:s' ); + $response->data['date'] = get_date_from_gmt( $report['date_gmt'], 'Y-m-d H:i:s' ); // Figure out file name. // Matches https://github.com/woocommerce/woocommerce/blob/4be0018c092e617c5d2b8c46b800eb71ece9ddef/includes/class-wc-download-handler.php#L197. - $product_id = intval( $data['product_id'] ); + $product_id = intval( $report['product_id'] ); $_product = wc_get_product( $product_id ); // Make sure the product hasn't been deleted. if ( $_product ) { - $file_path = $_product->get_file_download_path( $data['download_id'] ); + $file_path = $_product->get_file_download_path( $report['download_id'] ); $filename = basename( $file_path ); $response->data['file_name'] = apply_filters( 'woocommerce_file_download_filename', $filename, $product_id ); $response->data['file_path'] = $file_path; @@ -97,9 +74,9 @@ class Controller extends ReportsController implements ExportableInterface { $response->data['file_path'] = ''; } - $customer = new \WC_Customer( $data['user_id'] ); + $customer = new \WC_Customer( $report['user_id'] ); $response->data['username'] = $customer->get_username(); - $response->data['order_number'] = $this->get_order_number( $data['order_id'] ); + $response->data['order_number'] = $this->get_order_number( $report['order_id'] ); /** * Filter a report returned from the API. @@ -130,6 +107,22 @@ class Controller extends ReportsController implements ExportableInterface { return $links; } + /** + * Maps query arguments from the REST request. + * + * @param array $request Request array. + * @return array + */ + protected function prepare_reports_query( $request ) { + $args = array(); + $registered = array_keys( $this->get_collection_params() ); + foreach ( $registered as $param_name ) { + if ( isset( $request[ $param_name ] ) ) { + $args[ $param_name ] = $request[ $param_name ]; + } + } + return $args; + } /** * Get the Report's schema, conforming to JSON Schema. * @@ -225,53 +218,10 @@ class Controller extends ReportsController implements ExportableInterface { * @return array */ public function get_collection_params() { - $params = array(); - $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); - $params['page'] = array( - 'description' => __( 'Current page of the collection.', 'woocommerce' ), - 'type' => 'integer', - 'default' => 1, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - 'minimum' => 1, - ); - $params['per_page'] = array( - 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ), - 'type' => 'integer', - 'default' => 10, - 'minimum' => 1, - 'maximum' => 100, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['after'] = array( - 'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ), - 'type' => 'string', - 'format' => 'date-time', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['before'] = array( - 'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ), - 'type' => 'string', - 'format' => 'date-time', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['order'] = array( - 'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ), - 'type' => 'string', - 'default' => 'desc', - 'enum' => array( 'asc', 'desc' ), - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['orderby'] = array( - 'description' => __( 'Sort collection by object attribute.', 'woocommerce' ), - 'type' => 'string', - 'default' => 'date', - 'enum' => array( - 'date', - 'product', - ), - 'validate_callback' => 'rest_validate_request_arg', + $params = parent::get_collection_params(); + $params['orderby']['enum'] = array( + 'date', + 'product', ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: products, orders, username, ip_address.', 'woocommerce' ), @@ -355,12 +305,6 @@ class Controller extends ReportsController implements ExportableInterface { 'type' => 'string', ), ); - $params['force_cache_refresh'] = array( - 'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ), - 'type' => 'boolean', - 'sanitize_callback' => 'wp_validate_boolean', - 'validate_callback' => 'rest_validate_request_arg', - ); return $params; } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Downloads/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Downloads/DataStore.php index 59b14867e61..9adeebe8e32 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Downloads/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Downloads/DataStore.php @@ -20,6 +20,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * + * @override ReportsDataStore::$table_name + * * @var string */ protected static $table_name = 'wc_download_log'; @@ -27,6 +29,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override ReportsDataStore::$cache_key + * * @var string */ protected $cache_key = 'downloads'; @@ -34,6 +38,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * + * @override ReportsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -51,12 +57,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override ReportsDataStore::$context + * * @var string */ protected $context = 'downloads'; /** * Assign report columns once full table name has been assigned. + * + * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $this->report_columns = array( @@ -252,6 +262,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Gets WHERE time clause of SQL request with date-related constraints. * + * @override ReportsDataStore::add_time_period_sql_params() + * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. * @return string @@ -294,94 +306,89 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. + * @override ReportsDataStore::get_default_query_vars() + * + * @return array Query parameters. */ - public function get_data( $query_args ) { + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['orderby'] = 'timestamp'; + + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override ReportsDataStore::get_noncached_data() + * + * @see get_data + * @param array $query_args Query parameters. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_data( $query_args ) { global $wpdb; - $table_name = self::get_db_table_name(); + $this->initialize_queries(); - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'timestamp', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'fields' => '*', + $data = (object) array( + 'data' => array(), + 'total' => 0, + 'pages' => 0, + 'page_no' => 0, ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); + $selections = $this->selected_columns( $query_args ); + $this->add_sql_query_params( $query_args ); - if ( false === $data ) { - $this->initialize_queries(); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $db_records_count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM ( + {$this->subquery->get_query_statement()} + ) AS tt" + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $data = (object) array( - 'data' => array(), - 'total' => 0, - 'pages' => 0, - 'page_no' => 0, - ); - - $selections = $this->selected_columns( $query_args ); - $this->add_sql_query_params( $query_args ); - - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $db_records_count = (int) $wpdb->get_var( - "SELECT COUNT(*) FROM ( - {$this->subquery->get_query_statement()} - ) AS tt" - ); - // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - - $params = $this->get_limit_params( $query_args ); - $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return $data; - } - - $this->subquery->clear_sql_clause( 'select' ); - $this->subquery->add_sql_clause( 'select', $selections ); - $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - - $download_data = $wpdb->get_results( - $this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - ARRAY_A - ); - - if ( null === $download_data ) { - return $data; - } - - $download_data = array_map( array( $this, 'cast_numbers' ), $download_data ); - $data = (object) array( - 'data' => $download_data, - 'total' => $db_records_count, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - $this->set_cached_data( $cache_key, $data ); + $params = $this->get_limit_params( $query_args ); + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); + if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { + return $data; } + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + + $download_data = $wpdb->get_results( + $this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ARRAY_A + ); + + if ( null === $download_data ) { + return $data; + } + + $download_data = array_map( array( $this, 'cast_numbers' ), $download_data ); + $data = (object) array( + 'data' => $download_data, + 'total' => $db_records_count, + 'pages' => $total_pages, + 'page_no' => (int) $query_args['page'], + ); + return $data; } /** * Maps ordering specified by the user to columns in the database/fields in the data. * + * @override ReportsDataStore::normalize_order_by() + * * @param string $order_by Sorting criterion. * @return string */ diff --git a/plugins/woocommerce/src/Admin/API/Reports/Downloads/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Downloads/Query.php index 81d2e0c229c..d47c19deefb 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Downloads/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Downloads/Query.php @@ -21,24 +21,34 @@ use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Downloads\Query + * + * @deprecated 9.3.0 Downloads\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for downloads report. * + * @deprecated 9.3.0 Downloads\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array(); } /** * Get downloads data based on the current query vars. * + * @deprecated 9.3.0 Downloads\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_downloads_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-downloads' ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/Controller.php index 7d248213054..9d8e5f1bed2 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/Controller.php @@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats; defined( 'ABSPATH' ) || exit; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use WP_REST_Request; use WP_REST_Response; @@ -59,39 +60,22 @@ class Controller extends GenericStatsController { } /** - * Get all reports. + * Get data from `'downloads-stats'` GenericQuery. * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. */ - public function get_items( $request ) { - $query_args = $this->prepare_reports_query( $request ); - $downloads_query = new Query( $query_args ); - $report_data = $downloads_query->get_data(); - - $out_data = array( - 'totals' => get_object_vars( $report_data->totals ), - 'intervals' => array(), - ); - - foreach ( $report_data->intervals as $interval_data ) { - $item = $this->prepare_item_for_response( $interval_data, $request ); - $out_data['intervals'][] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $out_data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); + protected function get_datastore_data( $query_args = array() ) { + $query = new GenericQuery( $query_args, 'downloads-stats' ); + return $query->get_data(); } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param array $report Report data. + * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ @@ -110,7 +94,6 @@ class Controller extends GenericStatsController { return apply_filters( 'woocommerce_rest_prepare_report_downloads_stats', $response, $report, $request ); } - /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. @@ -129,6 +112,7 @@ class Controller extends GenericStatsController { ), ); } + /** * Get the Report's schema, conforming to JSON Schema. * It does not have the segments as in GenericStatsController. @@ -298,15 +282,6 @@ class Controller extends GenericStatsController { 'type' => 'string', ), ); - $params['fields'] = array( - 'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ), - 'type' => 'array', - 'sanitize_callback' => 'wp_parse_slug_list', - 'validate_callback' => 'rest_validate_request_arg', - 'items' => array( - 'type' => 'string', - ), - ); return $params; } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/DataStore.php index 97a1d8e3180..e82877bdd9b 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/DataStore.php @@ -10,16 +10,19 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore as DownloadsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; -use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; +use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Downloads\Stats\DataStore. */ class DataStore extends DownloadsDataStore implements DataStoreInterface { + use StatsDataStoreTrait; /** * Mapping columns to data type to return correct response types. * + * @override DownloadsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -29,6 +32,8 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override DownloadsDataStore::$cache_key + * * @var string */ protected $cache_key = 'downloads_stats'; @@ -36,12 +41,16 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override DownloadsDataStore::$context + * * @var string */ protected $context = 'downloads_stats'; /** * Assign report columns once full table name has been assigned. + * + * @override DownloadsDataStore::assign_report_columns() */ protected function assign_report_columns() { $this->report_columns = array( @@ -50,111 +59,100 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. + * @override DownloadsDataStore::default_query_args() + * + * @return array Query parameters. */ - public function get_data( $query_args ) { + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['interval'] = 'week'; + + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override DownloadsDataStore::get_noncached_data() + * + * @see get_data + * @see get_noncached_stats_data + * @param array $query_args Query parameters. + * @param array $params Query limit parameters. + * @param stdClass $data Reference to the data object to fill. + * @param int $expected_interval_count Number of expected intervals. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'date', - 'fields' => '*', - 'interval' => 'week', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), + $this->initialize_queries(); + $selections = $this->selected_columns( $query_args ); + $this->add_sql_query_params( $query_args ); + $where_time = $this->add_time_period_sql_params( $query_args, $table_name ); + $this->add_intervals_sql_params( $query_args, $table_name ); + + $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); + $this->interval_query->str_replace_clause( 'select', 'date_created', 'timestamp' ); + $this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' ); + + $db_intervals = $wpdb->get_col( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->interval_query->get_query_statement() ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); + $db_records_count = count( $db_intervals ); - if ( false === $data ) { - $this->initialize_queries(); - $selections = $this->selected_columns( $query_args ); - $this->add_sql_query_params( $query_args ); - $where_time = $this->add_time_period_sql_params( $query_args, $table_name ); - $this->add_intervals_sql_params( $query_args, $table_name ); + $this->update_intervals_sql_params( $query_args, $db_records_count, $expected_interval_count, $table_name ); + $this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' ); + $this->total_query->add_sql_clause( 'select', $selections ); + $this->total_query->add_sql_clause( 'where', $this->interval_query->get_sql_clause( 'where' ) ); + if ( $where_time ) { + $this->total_query->add_sql_clause( 'where_time', $where_time ); + } + $totals = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->total_query->get_query_statement(), + ARRAY_A + ); + if ( null === $totals ) { + return new \WP_Error( 'woocommerce_analytics_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) ); + } - $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); - $this->interval_query->str_replace_clause( 'select', 'date_created', 'timestamp' ); - $this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' ); + $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $this->interval_query->add_sql_clause( 'select', ', MAX(timestamp) AS datetime_anchor' ); + if ( '' !== $selections ) { + $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); + } - $db_intervals = $wpdb->get_col( - $this->interval_query->get_query_statement() - ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. + $intervals = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->interval_query->get_query_statement(), + ARRAY_A + ); - $db_records_count = count( $db_intervals ); + if ( null === $intervals ) { + return new \WP_Error( 'woocommerce_analytics_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) ); + } - $params = $this->get_limit_params( $query_args ); - $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); - $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return array(); - } + $totals = (object) $this->cast_numbers( $totals[0] ); - $this->update_intervals_sql_params( $query_args, $db_records_count, $expected_interval_count, $table_name ); - $this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' ); - $this->total_query->add_sql_clause( 'select', $selections ); - $this->total_query->add_sql_clause( 'where', $this->interval_query->get_sql_clause( 'where' ) ); - if ( $where_time ) { - $this->total_query->add_sql_clause( 'where_time', $where_time ); - } - $totals = $wpdb->get_results( - $this->total_query->get_query_statement(), - ARRAY_A - ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. - if ( null === $totals ) { - return new \WP_Error( 'woocommerce_analytics_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) ); - } + $data->totals = $totals; + $data->intervals = $intervals; - $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $this->interval_query->add_sql_clause( 'select', ', MAX(timestamp) AS datetime_anchor' ); - if ( '' !== $selections ) { - $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); - } - - $intervals = $wpdb->get_results( - $this->interval_query->get_query_statement(), - ARRAY_A - ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. - - if ( null === $intervals ) { - return new \WP_Error( 'woocommerce_analytics_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) ); - } - - $totals = (object) $this->cast_numbers( $totals[0] ); - $data = (object) array( - 'totals' => $totals, - 'intervals' => $intervals, - 'total' => $expected_interval_count, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - if ( $this->intervals_missing( $expected_interval_count, $db_records_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { - $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); - $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); - $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_records_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); - } else { - $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); - } - $this->create_interval_subtotals( $data->intervals ); - - $this->set_cached_data( $cache_key, $data ); + if ( $this->intervals_missing( $expected_interval_count, $db_records_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); + $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); + $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_records_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + } else { + $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } return $data; @@ -163,6 +161,8 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { /** * Normalizes order_by clause to match to SQL query. * + * @override DownloadsDataStore::normalize_order_by() + * * @param string $order_by Order by option requeste by user. * @return string */ @@ -173,18 +173,4 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { return $order_by; } - - /** - * Initialize query objects. - */ - protected function initialize_queries() { - $this->clear_all_clauses(); - unset( $this->subquery ); - $this->total_query = new SqlQuery( $this->context . '_total' ); - $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); - - $this->interval_query = new SqlQuery( $this->context . '_interval' ); - $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); - $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); - } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/Query.php index 95c56a105ec..059218e63f3 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Downloads/Stats/Query.php @@ -11,24 +11,34 @@ use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Downloads\Stats\Query + * + * @deprecated 9.3.0 Downloads\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Orders report. * + * @deprecated 9.3.0 Downloads\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array(); } /** * Get revenue data based on the current query vars. * + * @deprecated 9.3.0 Downloads\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_downloads_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-downloads-stats' ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/FilteredGetDataTrait.php b/plugins/woocommerce/src/Admin/API/Reports/FilteredGetDataTrait.php new file mode 100644 index 00000000000..957790a8ce3 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/Reports/FilteredGetDataTrait.php @@ -0,0 +1,58 @@ +context}_query_args` and + * `woocommerce_analytics_{$this->context}_select_query` on the `get_data` method. + * + * Example: + *
class MyStatsDataStore extends DataStore implements DataStoreInterface {
+ *     // Use the trait.
+ *     use FilteredGetDataTrait;
+ *     // Provide all the necessary properties and methods for a regular DataStore.
+ *     // ...
+ * }
+ * 
+ * + * @see DataStore + */ +trait FilteredGetDataTrait { + /** + * Get the data based on args. + * + * Filters query args, calls DataStore::get_data, and returns the filtered data. + * + * @override ReportsDataStore::get_data() + * + * @param array $query_args Query parameters. + * @return stdClass|WP_Error + */ + public function get_data( $query_args ) { + /** + * Called before the data is fetched. + * + * @since 9.3.0 + * @param array $query_args Query parameters. + */ + $args = apply_filters( "woocommerce_analytics_{$this->context}_query_args", $query_args ); + $results = parent::get_data( $args ); + /** + * Called after the data is fetched. + * The results can be modified here. + * + * @since 9.3.0 + * @param stdClass|WP_Error $results The results of the query. + */ + return apply_filters( "woocommerce_analytics_{$this->context}_select_query", $results, $args ); + } +} diff --git a/plugins/woocommerce/src/Admin/API/Reports/GenericController.php b/plugins/woocommerce/src/Admin/API/Reports/GenericController.php index 021d63b4588..2910e23b7d9 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/GenericController.php +++ b/plugins/woocommerce/src/Admin/API/Reports/GenericController.php @@ -7,10 +7,45 @@ use WP_REST_Request; use WP_REST_Response; /** - * WC REST API Reports controller extended - * to be shared as a generic base for all Analytics controllers. + * {@see WC_REST_Reports_Controller WC REST API Reports Controller} extended to be shared as a generic base for all Analytics reports controllers. + * + * Handles pagination HTTP headers and links, basic, conventional params. + * Does all the REST API plumbing as `WC_REST_Controller`. + * + * + * Minimalistic example: + *
class MyController extends GenericController {
+ *     /** Route of your new REST endpoint. */
+ *     protected $rest_base = 'reports/my-thing';
+ *     /**
+ *      * Provide JSON schema for the response item.
+ *      * @override WC_REST_Reports_Controller::get_item_schema()
+ *      */
+ *     public function get_item_schema() {
+ *         $schema = array(
+ *             '$schema'    => 'http://json-schema.org/draft-04/schema#',
+ *             'title'      => 'report_my_thing',
+ *             'type'       => 'object',
+ *             'properties' => array(
+ *                 'product_id' => array(
+ *                     'type'        => 'integer',
+ *                     'readonly'    => true,
+ *                     'context'     => array( 'view', 'edit' ),
+ *                 'description' => __( 'Product ID.', 'my_extension' ),
+ *                 ),
+ *             ),
+ *         );
+ *         // Add additional fields from `get_additional_fields` method and apply `woocommerce_rest_' . $schema['title'] . '_schema` filter.
+ *         return $this->add_additional_fields_schema( $schema );
+ *     }
+ * }
+ * 
+ * + * The above Controller will get the data from a {@see DataStore data store} registered as `$rest_base` (`reports/my-thing`). + * (To change this behavior, override the `get_datastore_data()` method). + * + * To use the controller, please register it with the filter `woocommerce_admin_rest_controllers` filter. * - * @internal * @extends WC_REST_Reports_Controller */ abstract class GenericController extends \WC_REST_Reports_Controller { @@ -26,12 +61,12 @@ abstract class GenericController extends \WC_REST_Reports_Controller { /** * Add pagination headers and links. * - * @param WP_REST_Request $request Request data. - * @param WP_REST_Response|array $response Response data. - * @param int $total Total results. - * @param int $page Current page. - * @param int $max_pages Total amount of pages. - * @return WP_REST_Response + * @param \WP_REST_Request $request Request data. + * @param \WP_REST_Response|array $response Response data. + * @param int $total Total results. + * @param int $page Current page. + * @param int $max_pages Total amount of pages. + * @return \WP_REST_Response */ public function add_pagination_headers( $request, $response, int $total, int $page, int $max_pages ) { $response = rest_ensure_response( $response ); @@ -62,7 +97,19 @@ abstract class GenericController extends \WC_REST_Reports_Controller { } /** - * Get the query params for collections. + * Get data from `{$this->rest_base}` store, based on the given query vars. + * + * @throws Exception When the data store is not found {@see WC_Data_Store WC_Data_Store}. + * @param array $query_args Query arguments. + * @return mixed Results from the data store. + */ + protected function get_datastore_data( $query_args = array() ) { + $data_store = \WC_Data_Store::load( $this->rest_base ); + return $data_store->get_data( $query_args ); + } + + /** + * Get the query params definition for collections. * * @return array */ @@ -124,15 +171,62 @@ abstract class GenericController extends \WC_REST_Reports_Controller { return $params; } + /** - * Prepare a report object for serialization. + * Get the report data. * - * @param array $report Report data. - * @param WP_REST_Request $request Request object. + * Prepares query params, fetches the report data from the data store, + * prepares it for the response, and packs it into the convention-conforming response object. + * + * @throws \WP_Error When the queried data is invalid. + * @param \WP_REST_Request $request Request data. + * @return \WP_Error|\WP_REST_Response + */ + public function get_items( $request ) { + $query_args = $this->prepare_reports_query( $request ); + $report_data = $this->get_datastore_data( $query_args ); + + if ( is_wp_error( $report_data ) ) { + return $report_data; + } + + if ( ! isset( $report_data->data ) || ! isset( $report_data->page_no ) || ! isset( $report_data->pages ) ) { + return new \WP_Error( 'woocommerce_rest_reports_invalid_response', __( 'Invalid response from data store.', 'woocommerce' ), array( 'status' => 500 ) ); + } + + $out_data = array(); + + foreach ( $report_data->data as $datum ) { + $item = $this->prepare_item_for_response( $datum, $request ); + $out_data[] = $this->prepare_response_for_collection( $item ); + } + + return $this->add_pagination_headers( + $request, + $out_data, + (int) $report_data->total, + (int) $report_data->page_no, + (int) $report_data->pages + ); + } + + /** + * Prepare a report data item for serialization. + * + * This method is called by `get_items` to prepare a single report data item for serialization. + * Calls `add_additional_fields_to_object` and `filter_response_by_context`, + * then wpraps the data with `rest_ensure_response`. + * + * You can extend it to add or filter some fields. + * + * @override WP_REST_Posts_Controller::prepare_item_for_response() + * + * @param mixed $report_item Report data item as returned from Data Store. + * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ - public function prepare_item_for_response( $report, $request ) { - $data = $report; + public function prepare_item_for_response( $report_item, $request ) { + $data = $report_item; $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); @@ -141,4 +235,26 @@ abstract class GenericController extends \WC_REST_Reports_Controller { // Wrap the data in a response object. return rest_ensure_response( $data ); } + + /** + * Maps query arguments from the REST request, to be used to query the datastore. + * + * `WP_REST_Request` does not expose a method to return all params covering defaults, + * as it does for `$request['param']` accessor. + * Therefore, we re-implement defaults resolution. + * + * @param \WP_REST_Request $request Full request object. + * @return array Simplified array of params. + */ + protected function prepare_reports_query( $request ) { + $args = wp_parse_args( + array_intersect_key( + $request->get_query_params(), + $this->get_collection_params() + ), + $request->get_default_params() + ); + + return $args; + } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/GenericQuery.php b/plugins/woocommerce/src/Admin/API/Reports/GenericQuery.php new file mode 100644 index 00000000000..8b8e4c03e2f --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/Reports/GenericQuery.php @@ -0,0 +1,91 @@ +$args = array( + * 'before' => '2018-07-19 00:00:00', + * 'after' => '2018-07-05 00:00:00', + * 'page' => 2, + * ); + * $report = new GenericQuery( $args, 'coupons' ); + * $mydata = $report->get_data(); + * + * + * It uses the name provided in the class property or in the constructor call to load the `report-{name}` data store. + * + * It's used by the {@see GenericController GenericController}. + * + * @since 9.3.0 + */ +class GenericQuery extends \WC_Object_Query { + + /** + * Specific query name. + * Will be used to load the `report-{name}` data store, + * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. + * + * @var string + */ + protected $name; + + /** + * Create a new query. + * + * @param array $args Criteria to query on in a format similar to WP_Query. + * @param string $name Query name. + * @extends WC_Object_Query::_construct + */ + public function __construct( $args, $name = null ) { + $this->name = $name ?? $this->name; + + return parent::__construct( $args ); // phpcs:ignore Universal.CodeAnalysis.ConstructorDestructorReturn.ReturnValueFound + } + /** + * Valid fields for Products report. + * + * @return array + */ + protected function get_default_query_vars() { + return array(); + } + + /** + * Get data from `report-{$name}` store, based on the current query vars. + * Filters query vars through `woocommerce_analytics_{snake_case(name)}_query_args` filter. + * Filters results through `woocommerce_analytics_{snake_case(name)}_select_query` filter. + * + * @return mixed filtered results from the data store. + */ + public function get_data() { + $snake_name = str_replace( '-', '_', $this->name ); + /** + * Filter query args given for the report. + * + * @since 9.3.0 + * + * @param array $query_args Query args. + */ + $args = apply_filters( "woocommerce_analytics_{$snake_name}_query_args", $this->get_query_vars() ); + + $data_store = \WC_Data_Store::load( "report-{$this->name}" ); + $results = $data_store->get_data( $args ); + /** + * Filter report query results. + * + * @since 9.3.0 + * + * @param stdClass|WP_Error $results Results from the data store. + * @param array $args Query args used to get the data (potentially filtered). + */ + return apply_filters( "woocommerce_analytics_{$snake_name}_select_query", $results, $args ); + } +} diff --git a/plugins/woocommerce/src/Admin/API/Reports/GenericStatsController.php b/plugins/woocommerce/src/Admin/API/Reports/GenericStatsController.php index 5852ec0b460..5ee255356fa 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/GenericStatsController.php +++ b/plugins/woocommerce/src/Admin/API/Reports/GenericStatsController.php @@ -6,21 +6,69 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; /** - * Generic base for all Stats controllers. + * Generic base for all stats controllers. + * + * {@see GenericController Generic Controller} extended to be shared as a generic base for all Analytics stats controllers. + * + * Besides the `GenericController` functionality, it adds conventional stats-specific collection params and item schema. + * So, you may want to extend only your report-specific {@see get_item_properties_schema() get_item_properties_schema()}`. + * It also uses the stats-specific {@see get_items() get_items()} method, + * which packs report data into `totals` and `intervals`. + * + * + * Minimalistic example: + *
class StatsController extends GenericStatsController {
+ *     /** Route of your new REST endpoint. */
+ *     protected $rest_base = 'reports/my-thing/stats';
+ *     /** Define your proeprties schema. */
+ *     protected function get_item_properties_schema() {
+ *         return array(
+ *             'my_property' => array(
+ *                 'title'       => __( 'My property', 'my-extension' ),
+ *                 'type'        => 'integer',
+ *                 'readonly'    => true,
+ *                 'context'     => array( 'view', 'edit' ),
+ *                 'description' => __( 'Amazing thing.', 'my-extension' ),
+ *                 'indicator'    => true,
+ *              ),
+ *         );
+ *     }
+ *     /** Define overall schema. You can use the defaults,
+ *      * just remember to provide your title and call `add_additional_fields_schema`
+ *      * to run the filters
+ *      */
+ *     public function get_item_schema() {
+ *         $schema          = parent::get_item_schema();
+ *         $schema['title'] = 'report_my_thing_stats';
+ *
+ *        return $this->add_additional_fields_schema( $schema );
+ *     }
+ * }
+ * 
* - * @internal * @extends GenericController */ abstract class GenericStatsController extends GenericController { /** - * Get the query params for collections. - * Adds intervals to the generic list. + * Get the query params definition for collections. + * Adds `fields` & `intervals` to the generic list. + * + * @override GenericController::get_collection_params() * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); + $params['fields'] = array( + 'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ), + 'type' => 'array', + 'sanitize_callback' => 'wp_parse_slug_list', + 'validate_callback' => 'rest_validate_request_arg', + 'items' => array( + 'type' => 'string', + ), + ); $params['interval'] = array( 'description' => __( 'Time interval to use for buckets in the returned data.', 'woocommerce' ), 'type' => 'string', @@ -40,7 +88,7 @@ abstract class GenericStatsController extends GenericController { } /** - * Get the Report's item properties schema. + * Get the report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array @@ -50,7 +98,7 @@ abstract class GenericStatsController extends GenericController { /** * Get the Report's schema, conforming to JSON Schema. * - * Please note, it does not call add_additional_fields_schema, + * Please note that it does not call add_additional_fields_schema, * as you may want to update the `title` first. * * @return array @@ -155,4 +203,43 @@ abstract class GenericStatsController extends GenericController { ), ); } + + /** + * Get the report data. + * + * Prepares query params, fetches the report data from the data store, + * prepares it for the response, and packs it into the convention-conforming response object. + * + * @override GenericController::get_items() + * + * @throws \WP_Error When the queried data is invalid. + * @param \WP_REST_Request $request Request data. + * @return \WP_REST_Response|\WP_Error + */ + public function get_items( $request ) { + $query_args = $this->prepare_reports_query( $request ); + try { + $report_data = $this->get_datastore_data( $query_args ); + } catch ( ParameterException $e ) { + return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); + } + + $out_data = array( + 'totals' => $report_data->totals ? get_object_vars( $report_data->totals ) : null, + 'intervals' => array(), + ); + + foreach ( $report_data->intervals as $interval_data ) { + $item = $this->prepare_item_for_response( $interval_data, $request ); + $out_data['intervals'][] = $this->prepare_response_for_collection( $item ); + } + + return $this->add_pagination_headers( + $request, + $out_data, + (int) $report_data->total, + (int) $report_data->page_no, + (int) $report_data->pages + ); + } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/OrderAwareControllerTrait.php b/plugins/woocommerce/src/Admin/API/Reports/OrderAwareControllerTrait.php new file mode 100644 index 00000000000..bf1f28ba39c --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/Reports/OrderAwareControllerTrait.php @@ -0,0 +1,123 @@ +is_valid_order( $order ) ) { + return null; + } + + if ( 'shop_order_refund' === $order->get_type() ) { + $order = wc_get_order( $order->get_parent_id() ); + + // If the parent order doesn't exist, return null. + if ( ! $this->is_valid_order( $order ) ) { + return null; + } + } + + if ( ! has_filter( 'woocommerce_order_number' ) ) { + return $order->get_id(); + } + + return $order->get_order_number(); + } + + /** + * Whether the order is valid. + * + * @param bool|WC_Order|WC_Order_Refund $order Order object. + * @return bool True if the order is valid, false otherwise. + */ + protected function is_valid_order( $order ) { + return $order instanceof \WC_Order || $order instanceof \WC_Order_Refund; + } + + /** + * Get the order total with the related currency formatting. + * Returns the parent order total if the order is actually a refund. + * + * @param int $order_id Order ID. + * @return string|null The Order Number or null if the order doesn't exist. + */ + protected function get_total_formatted( $order_id ) { + $order = wc_get_order( $order_id ); + + if ( ! $this->is_valid_order( $order ) ) { + return null; + } + + if ( 'shop_order_refund' === $order->get_type() ) { + $order = wc_get_order( $order->get_parent_id() ); + + if ( ! $this->is_valid_order( $order ) ) { + return null; + } + } + + return wp_strip_all_tags( html_entity_decode( $order->get_formatted_order_total() ), true ); + } + + /** + * Get order statuses without prefixes. + * Includes unregistered statuses that have been marked "actionable". + * + * @return array + */ + public static function get_order_statuses() { + // Allow all statuses selected as "actionable" - this may include unregistered statuses. + // See: https://github.com/woocommerce/woocommerce-admin/issues/5592. + $actionable_statuses = get_option( 'woocommerce_actionable_order_statuses', array() ); + + // See WC_REST_Orders_V2_Controller::get_collection_params() re: any/trash statuses. + $registered_statuses = array_merge( array( 'any', 'trash' ), array_keys( self::get_order_status_labels() ) ); + + // Merge the status arrays (using flip to avoid array_unique()). + $allowed_statuses = array_keys( array_merge( array_flip( $registered_statuses ), array_flip( $actionable_statuses ) ) ); + + return $allowed_statuses; + } + + /** + * Get order statuses (and labels) without prefixes. + * + * @internal + * @return array + */ + public static function get_order_status_labels() { + $order_statuses = array(); + + foreach ( wc_get_order_statuses() as $key => $label ) { + $new_key = str_replace( 'wc-', '', $key ); + $order_statuses[ $new_key ] = $label; + } + + return $order_statuses; + } +} diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php index 81e85182f1c..2a6b81fad94 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php @@ -9,16 +9,19 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Orders; defined( 'ABSPATH' ) || exit; -use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; +use Automattic\WooCommerce\Admin\API\Reports\GenericController; +use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * REST API Reports orders controller class. * * @internal - * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller + * @extends \Automattic\WooCommerce\Admin\API\Reports\GenericController */ -class Controller extends ReportsController implements ExportableInterface { +class Controller extends GenericController implements ExportableInterface { + + use OrderAwareControllerTrait; /** * Route base. @@ -27,6 +30,19 @@ class Controller extends ReportsController implements ExportableInterface { */ protected $rest_base = 'reports/orders'; + /** + * Get data from Orders\Query. + * + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. + */ + protected function get_datastore_data( $query_args = array() ) { + $query = new Query( $query_args ); + return $query->get_data(); + } + /** * Maps query arguments from the REST request. * @@ -65,50 +81,17 @@ class Controller extends ReportsController implements ExportableInterface { } /** - * Get all reports. + * Prepare a report data item for serialization. * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error - */ - public function get_items( $request ) { - $query_args = $this->prepare_reports_query( $request ); - $orders_query = new Query( $query_args ); - $report_data = $orders_query->get_data(); - - $data = array(); - - foreach ( $report_data->data as $orders_data ) { - $orders_data['order_number'] = $this->get_order_number( $orders_data['order_id'] ); - $orders_data['total_formatted'] = $this->get_total_formatted( $orders_data['order_id'] ); - $item = $this->prepare_item_for_response( $orders_data, $request ); - $data[] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); - } - - /** - * Prepare a report object for serialization. - * - * @param stdClass $report Report data. - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response + * @param array $report Report data item as returned from Data Store. + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { - $data = $report; - - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $data = $this->add_additional_fields_to_object( $data, $request ); - $data = $this->filter_response_by_context( $data, $context ); - + $report['order_number'] = $this->get_order_number( $report['order_id'] ); + $report['total_formatted'] = $this->get_total_formatted( $report['order_id'] ); // Wrap the data in a response object. - $response = rest_ensure_response( $data ); + $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** @@ -248,54 +231,12 @@ class Controller extends ReportsController implements ExportableInterface { * @return array */ public function get_collection_params() { - $params = array(); - $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); - $params['page'] = array( - 'description' => __( 'Current page of the collection.', 'woocommerce' ), - 'type' => 'integer', - 'default' => 1, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - 'minimum' => 1, - ); - $params['per_page'] = array( - 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ), - 'type' => 'integer', - 'default' => 10, - 'minimum' => 0, - 'maximum' => 100, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['after'] = array( - 'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ), - 'type' => 'string', - 'format' => 'date-time', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['before'] = array( - 'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ), - 'type' => 'string', - 'format' => 'date-time', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['order'] = array( - 'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ), - 'type' => 'string', - 'default' => 'desc', - 'enum' => array( 'asc', 'desc' ), - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['orderby'] = array( - 'description' => __( 'Sort collection by object attribute.', 'woocommerce' ), - 'type' => 'string', - 'default' => 'date', - 'enum' => array( - 'date', - 'num_items_sold', - 'net_total', - ), - 'validate_callback' => 'rest_validate_request_arg', + $params = parent::get_collection_params(); + $params['per_page']['minimum'] = 0; + $params['orderby']['enum'] = array( + 'date', + 'num_items_sold', + 'net_total', ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ), @@ -464,12 +405,6 @@ class Controller extends ReportsController implements ExportableInterface { 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); - $params['force_cache_refresh'] = array( - 'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ), - 'type' => 'boolean', - 'sanitize_callback' => 'wp_validate_boolean', - 'validate_callback' => 'rest_validate_request_arg', - ); return $params; } @@ -524,14 +459,13 @@ class Controller extends ReportsController implements ExportableInterface { $export_columns = array( 'date_created' => __( 'Date', 'woocommerce' ), 'order_number' => __( 'Order #', 'woocommerce' ), - 'total_formatted' => __( 'N. Revenue (formatted)', 'woocommerce' ), 'status' => __( 'Status', 'woocommerce' ), 'customer_name' => __( 'Customer', 'woocommerce' ), 'customer_type' => __( 'Customer type', 'woocommerce' ), 'products' => __( 'Product(s)', 'woocommerce' ), 'num_items_sold' => __( 'Items sold', 'woocommerce' ), 'coupons' => __( 'Coupon(s)', 'woocommerce' ), - 'net_total' => __( 'N. Revenue', 'woocommerce' ), + 'net_total' => __( 'Net Sales', 'woocommerce' ), 'attribution' => __( 'Attribution', 'woocommerce' ), ); @@ -555,16 +489,15 @@ class Controller extends ReportsController implements ExportableInterface { */ public function prepare_item_for_export( $item ) { $export_item = array( - 'date_created' => $item['date_created'], + 'date_created' => $item['date'], 'order_number' => $item['order_number'], - 'total_formatted' => $item['total_formatted'], 'status' => $item['status'], 'customer_name' => isset( $item['extended_info']['customer'] ) ? $this->get_customer_name( $item['extended_info']['customer'] ) : null, 'customer_type' => $item['customer_type'], 'products' => isset( $item['extended_info']['products'] ) ? $this->get_products( $item['extended_info']['products'] ) : null, 'num_items_sold' => $item['num_items_sold'], 'coupons' => isset( $item['extended_info']['coupons'] ) ? $this->get_coupons( $item['extended_info']['coupons'] ) : null, - 'net_total' => $item['net_total'], + 'net_total' => $item['net_total'], 'attribution' => $item['extended_info']['attribution']['origin'], ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php index 007bcf16ea8..7d42995158b 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php @@ -14,7 +14,6 @@ use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Admin\API\Reports\Cache; -use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; /** @@ -25,6 +24,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Dynamically sets the date column name based on configuration + * + * @override ReportsDataStore::__construct() */ public function __construct() { $this->date_column_name = get_option( 'woocommerce_date_type', 'date_paid' ); @@ -34,6 +35,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * + * @override ReportsDataStore::$table_name + * * @var string */ protected static $table_name = 'wc_order_stats'; @@ -41,6 +44,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override ReportsDataStore::$cache_key + * * @var string */ protected $cache_key = 'orders'; @@ -48,6 +53,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * + * @override ReportsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -66,16 +73,20 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override ReportsDataStore::$context + * * @var string */ protected $context = 'orders'; /** * Assign report columns once full table name has been assigned. + * + * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); - // Avoid ambigious columns in SQL query. + // Avoid ambiguous columns in SQL query. $this->report_columns = array( 'order_id' => "DISTINCT {$table_name}.order_id", 'parent_id' => "{$table_name}.parent_id", @@ -213,117 +224,118 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. + * @override ReportsDataStore::get_default_query_vars() + * + * @return array Query parameters. */ - public function get_data( $query_args ) { + public function get_default_query_vars() { + $defaults = array_merge( + parent::get_default_query_vars(), + array( + 'orderby' => $this->date_column_name, + 'product_includes' => array(), + 'product_excludes' => array(), + 'coupon_includes' => array(), + 'coupon_excludes' => array(), + 'tax_rate_includes' => array(), + 'tax_rate_excludes' => array(), + 'customer_type' => null, + 'status_is' => array(), + 'extended_info' => false, + 'refunds' => null, + 'order_includes' => array(), + 'order_excludes' => array(), + ) + ); + + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override ReportsDataStore::get_noncached_data() + * + * @see get_data + * @param array $query_args Query parameters. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_data( $query_args ) { global $wpdb; - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => $this->date_column_name, - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'fields' => '*', - 'product_includes' => array(), - 'product_excludes' => array(), - 'coupon_includes' => array(), - 'coupon_excludes' => array(), - 'tax_rate_includes' => array(), - 'tax_rate_excludes' => array(), - 'customer_type' => null, - 'status_is' => array(), - 'extended_info' => false, - 'refunds' => null, - 'order_includes' => array(), - 'order_excludes' => array(), + $this->initialize_queries(); + + $data = (object) array( + 'data' => array(), + 'total' => 0, + 'pages' => 0, + 'page_no' => 0, ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); - - if ( false === $data ) { - $this->initialize_queries(); + $selections = $this->selected_columns( $query_args ); + $params = $this->get_limit_params( $query_args ); + $this->add_sql_query_params( $query_args ); + /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ + $db_records_count = (int) $wpdb->get_var( + "SELECT COUNT( DISTINCT tt.order_id ) FROM ( + {$this->subquery->get_query_statement()} + ) AS tt" + ); + /* phpcs:enable */ + if ( 0 === $params['per_page'] ) { + $total_pages = 0; + } else { + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); + } + if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { $data = (object) array( 'data' => array(), - 'total' => 0, + 'total' => $db_records_count, 'pages' => 0, 'page_no' => 0, ); - - $selections = $this->selected_columns( $query_args ); - $params = $this->get_limit_params( $query_args ); - $this->add_sql_query_params( $query_args ); - /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ - $db_records_count = (int) $wpdb->get_var( - "SELECT COUNT( DISTINCT tt.order_id ) FROM ( - {$this->subquery->get_query_statement()} - ) AS tt" - ); - /* phpcs:enable */ - - if ( 0 === $params['per_page'] ) { - $total_pages = 0; - } else { - $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); - } - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - $data = (object) array( - 'data' => array(), - 'total' => $db_records_count, - 'pages' => 0, - 'page_no' => 0, - ); - return $data; - } - - $this->subquery->clear_sql_clause( 'select' ); - $this->subquery->add_sql_clause( 'select', $selections ); - $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ - $orders_data = $wpdb->get_results( - $this->subquery->get_query_statement(), - ARRAY_A - ); - /* phpcs:enable */ - - if ( null === $orders_data ) { - return $data; - } - - if ( $query_args['extended_info'] ) { - $this->include_extended_info( $orders_data, $query_args ); - } - - $orders_data = array_map( array( $this, 'cast_numbers' ), $orders_data ); - $data = (object) array( - 'data' => $orders_data, - 'total' => $db_records_count, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - $this->set_cached_data( $cache_key, $data ); + return $data; } + + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ + $orders_data = $wpdb->get_results( + $this->subquery->get_query_statement(), + ARRAY_A + ); + /* phpcs:enable */ + + if ( null === $orders_data ) { + return $data; + } + + if ( $query_args['extended_info'] ) { + $this->include_extended_info( $orders_data, $query_args ); + } + + $orders_data = array_map( array( $this, 'cast_numbers' ), $orders_data ); + $data = (object) array( + 'data' => $orders_data, + 'total' => $db_records_count, + 'pages' => $total_pages, + 'page_no' => (int) $query_args['page'], + ); return $data; } /** * Normalizes order_by clause to match to SQL query. * + * @override ReportsDataStore::normalize_order_by() + * * @param string $order_by Order by option requeste by user. * @return string */ diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Query.php index bb28600ed9f..bf21d709fb8 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Query.php @@ -19,24 +19,32 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Orders; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; + defined( 'ABSPATH' ) || exit; -use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Orders\Query */ -class Query extends ReportsQuery { +class Query extends GenericQuery { /** - * Get order data based on the current query vars. + * Specific query name. + * Will be used to load the `report-{name}` data store, + * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. + * + * @var string + */ + protected $name = 'orders'; + + + /** + * Get the default allowed query vars. * * @return array */ - public function get_data() { - $args = apply_filters( 'woocommerce_analytics_orders_query_args', $this->get_query_vars() ); - $data_store = \WC_Data_Store::load( 'report-orders' ); - $results = $data_store->get_data( $args ); - return apply_filters( 'woocommerce_analytics_orders_select_query', $results, $args ); + protected function get_default_query_vars() { + return \WC_Object_Query::get_default_query_vars(); } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Controller.php index 0f835bd6d14..ee5d3f24a4f 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Controller.php @@ -9,15 +9,19 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats; defined( 'ABSPATH' ) || exit; -use Automattic\WooCommerce\Admin\API\Reports\ParameterException; +use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; +use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; +use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query; /** * REST API Reports orders stats controller class. * * @internal - * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller + * @extends \Automattic\WooCommerce\Admin\API\Reports\GenericStatsController */ -class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { +class Controller extends GenericStatsController { + + use OrderAwareControllerTrait; /** * Route base. @@ -26,6 +30,19 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { */ protected $rest_base = 'reports/orders/stats'; + /** + * Get data from Orders\Stats\Query. + * + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. + */ + protected function get_datastore_data( $query_args = array() ) { + $query = new Query( $query_args ); + return $query->get_data(); + } + /** * Maps query arguments from the REST request. * @@ -70,55 +87,15 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { } /** - * Get all reports. + * Prepare a report data item for serialization. * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error - */ - public function get_items( $request ) { - $query_args = $this->prepare_reports_query( $request ); - $orders_query = new Query( $query_args ); - try { - $report_data = $orders_query->get_data(); - } catch ( ParameterException $e ) { - return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); - } - - $out_data = array( - 'totals' => get_object_vars( $report_data->totals ), - 'intervals' => array(), - ); - - foreach ( $report_data->intervals as $interval_data ) { - $item = $this->prepare_item_for_response( $interval_data, $request ); - $out_data['intervals'][] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $out_data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); - } - - /** - * Prepare a report object for serialization. - * - * @param Array $report Report data. + * @param Array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { - $data = $report; - - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $data = $this->add_additional_fields_to_object( $data, $request ); - $data = $this->filter_response_by_context( $data, $context ); - // Wrap the data in a response object. - $response = rest_ensure_response( $data ); + $response = parent::prepare_item_for_response( $report, $request ); /** * Filter a report returned from the API. @@ -132,13 +109,15 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { return apply_filters( 'woocommerce_rest_prepare_report_orders_stats', $response, $report, $request ); } + /** - * Get the Report's schema, conforming to JSON Schema. + * Get the Report's item properties schema. + * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ - public function get_item_schema() { - $data_values = array( + protected function get_item_properties_schema() { + return array( 'net_revenue' => array( 'description' => __( 'Net sales.', 'woocommerce' ), 'type' => 'number', @@ -199,104 +178,19 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { 'readonly' => true, ), ); + } - $segments = array( - 'segments' => array( - 'description' => __( 'Reports data grouped by segment condition.', 'woocommerce' ), - 'type' => 'array', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'segment_id' => array( - 'description' => __( 'Segment identificator.', 'woocommerce' ), - 'type' => 'integer', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - ), - 'subtotals' => array( - 'description' => __( 'Interval subtotals.', 'woocommerce' ), - 'type' => 'object', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - 'properties' => $data_values, - ), - ), - ), - ), - ); - - $totals = array_merge( $data_values, $segments ); + /** + * Get the Report's schema, conforming to JSON Schema. + * + * @return array + */ + public function get_item_schema() { + $schema = parent::get_item_schema(); + $schema['title'] = 'report_orders_stats'; // Products is not shown in intervals. - unset( $data_values['products'] ); - - $intervals = array_merge( $data_values, $segments ); - - $schema = array( - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'title' => 'report_orders_stats', - 'type' => 'object', - 'properties' => array( - 'totals' => array( - 'description' => __( 'Totals data.', 'woocommerce' ), - 'type' => 'object', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - 'properties' => $totals, - ), - 'intervals' => array( - 'description' => __( 'Reports data grouped by intervals.', 'woocommerce' ), - 'type' => 'array', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'interval' => array( - 'description' => __( 'Type of interval.', 'woocommerce' ), - 'type' => 'string', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - 'enum' => array( 'day', 'week', 'month', 'year' ), - ), - 'date_start' => array( - 'description' => __( "The date the report start, in the site's timezone.", 'woocommerce' ), - 'type' => 'date-time', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - ), - 'date_start_gmt' => array( - 'description' => __( 'The date the report start, as GMT.', 'woocommerce' ), - 'type' => 'date-time', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - ), - 'date_end' => array( - 'description' => __( "The date the report end, in the site's timezone.", 'woocommerce' ), - 'type' => 'date-time', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - ), - 'date_end_gmt' => array( - 'description' => __( 'The date the report end, as GMT.', 'woocommerce' ), - 'type' => 'date-time', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - ), - 'subtotals' => array( - 'description' => __( 'Interval subtotals.', 'woocommerce' ), - 'type' => 'object', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - 'properties' => $intervals, - ), - ), - ), - ), - ), - ); + unset( $schema['properties']['intervals']['items']['properties']['subtotals']['properties']['products'] ); return $this->add_additional_fields_schema( $schema ); } @@ -307,69 +201,12 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { * @return array */ public function get_collection_params() { - $params = array(); - $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); - $params['page'] = array( - 'description' => __( 'Current page of the collection.', 'woocommerce' ), - 'type' => 'integer', - 'default' => 1, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - 'minimum' => 1, - ); - $params['per_page'] = array( - 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ), - 'type' => 'integer', - 'default' => 10, - 'minimum' => 1, - 'maximum' => 100, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['after'] = array( - 'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ), - 'type' => 'string', - 'format' => 'date-time', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['before'] = array( - 'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ), - 'type' => 'string', - 'format' => 'date-time', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['order'] = array( - 'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ), - 'type' => 'string', - 'default' => 'desc', - 'enum' => array( 'asc', 'desc' ), - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['orderby'] = array( - 'description' => __( 'Sort collection by object attribute.', 'woocommerce' ), - 'type' => 'string', - 'default' => 'date', - 'enum' => array( - 'date', - 'net_revenue', - 'orders_count', - 'avg_order_value', - ), - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['interval'] = array( - 'description' => __( 'Time interval to use for buckets in the returned data.', 'woocommerce' ), - 'type' => 'string', - 'default' => 'week', - 'enum' => array( - 'hour', - 'day', - 'week', - 'month', - 'quarter', - 'year', - ), - 'validate_callback' => 'rest_validate_request_arg', + $params = parent::get_collection_params(); + $params['orderby']['enum'] = array( + 'date', + 'net_revenue', + 'orders_count', + 'avg_order_value', ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), @@ -412,7 +249,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { 'sanitize_callback' => 'wp_parse_id_list', ); - $params['product_excludes'] = array( + $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -421,7 +258,8 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); - $params['variation_includes'] = array( + // Split assignments for PHPCS complaining on aligned. + $params['variation_includes'] = array( 'description' => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -431,7 +269,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); - $params['variation_excludes'] = array( + $params['variation_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -441,7 +279,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); - $params['coupon_includes'] = array( + $params['coupon_includes'] = array( 'description' => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -450,7 +288,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); - $params['coupon_excludes'] = array( + $params['coupon_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -459,7 +297,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); - $params['tax_rate_includes'] = array( + $params['tax_rate_includes'] = array( 'description' => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -469,7 +307,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); - $params['tax_rate_excludes'] = array( + $params['tax_rate_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -479,7 +317,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); - $params['customer'] = array( + $params['customer'] = array( 'description' => __( 'Alias for customer_type (deprecated).', 'woocommerce' ), 'type' => 'string', 'enum' => array( @@ -488,7 +326,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { ), 'validate_callback' => 'rest_validate_request_arg', ); - $params['customer_type'] = array( + $params['customer_type'] = array( 'description' => __( 'Limit result set to orders that have the specified customer_type', 'woocommerce' ), 'type' => 'string', 'enum' => array( @@ -497,7 +335,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { ), 'validate_callback' => 'rest_validate_request_arg', ); - $params['refunds'] = array( + $params['refunds'] = array( 'description' => __( 'Limit result set to specific types of refunds.', 'woocommerce' ), 'type' => 'string', 'default' => '', @@ -510,7 +348,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { ), 'validate_callback' => 'rest_validate_request_arg', ); - $params['attribute_is'] = array( + $params['attribute_is'] = array( 'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -519,7 +357,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); - $params['attribute_is_not'] = array( + $params['attribute_is_not'] = array( 'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -528,7 +366,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); - $params['segmentby'] = array( + $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( @@ -540,21 +378,8 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { ), 'validate_callback' => 'rest_validate_request_arg', ); - $params['fields'] = array( - 'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ), - 'type' => 'array', - 'sanitize_callback' => 'wp_parse_slug_list', - 'validate_callback' => 'rest_validate_request_arg', - 'items' => array( - 'type' => 'string', - ), - ); - $params['force_cache_refresh'] = array( - 'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ), - 'type' => 'boolean', - 'sanitize_callback' => 'wp_validate_boolean', - 'validate_callback' => 'rest_validate_request_arg', - ); + unset( $params['intervals'] ); + unset( $params['fields'] ); return $params; } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php index e99e7bfd6bb..4652a2b3a16 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php @@ -14,15 +14,19 @@ use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore; use Automattic\WooCommerce\Utilities\OrderUtil; +use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Orders\Stats\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { + use StatsDataStoreTrait; /** * Table used to get the data. * + * @override ReportsDataStore::$table_name + * * @var string */ protected static $table_name = 'wc_order_stats'; @@ -35,6 +39,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override ReportsDataStore::$cache_key + * * @var string */ protected $cache_key = 'orders_stats'; @@ -42,6 +48,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Type for each column to cast values correctly later. * + * @override ReportsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -65,12 +73,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override ReportsDataStore::$context + * * @var string */ protected $context = 'orders_stats'; /** * Dynamically sets the date column name based on configuration + * + * @override ReportsDataStore::__construct() */ public function __construct() { $this->date_column_name = get_option( 'woocommerce_date_type', 'date_paid' ); @@ -79,10 +91,12 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Assign report columns once full table name has been assigned. + * + * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); - // Avoid ambigious columns in SQL query. + // Avoid ambiguous columns in SQL query. $refunds = "ABS( SUM( CASE WHEN {$table_name}.net_total < 0 THEN {$table_name}.net_total ELSE 0 END ) )"; $gross_sales = "( SUM({$table_name}.total_sales)" . @@ -260,176 +274,161 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. + * @override ReportsDataStore::get_default_query_vars() + * + * @return array Query parameters. */ - public function get_data( $query_args ) { + public function get_default_query_vars() { + $defaults = array_merge( + parent::get_default_query_vars(), + array( + 'interval' => 'week', + 'segmentby' => '', + + 'match' => 'all', + 'status_is' => array(), + 'status_is_not' => array(), + 'product_includes' => array(), + 'product_excludes' => array(), + 'coupon_includes' => array(), + 'coupon_excludes' => array(), + 'tax_rate_includes' => array(), + 'tax_rate_excludes' => array(), + 'customer_type' => '', + 'category_includes' => array(), + ) + ); + + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override ReportsDataStore::get_noncached_stats_data() + * + * @see get_data + * @see get_noncached_stats_data + * @param array $query_args Query parameters. + * @param array $params Query limit parameters. + * @param stdClass $data Reference to the data object to fill. + * @param int $expected_interval_count Number of expected intervals. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); - // These defaults are only applied when not using REST API, as the API has its own defaults that overwrite these for most values (except before, after, etc). - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'date', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'interval' => 'week', - 'fields' => '*', - 'segmentby' => '', - - 'match' => 'all', - 'status_is' => array(), - 'status_is_not' => array(), - 'product_includes' => array(), - 'product_excludes' => array(), - 'coupon_includes' => array(), - 'coupon_excludes' => array(), - 'tax_rate_includes' => array(), - 'tax_rate_excludes' => array(), - 'customer_type' => '', - 'category_includes' => array(), - ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); - if ( isset( $query_args['date_type'] ) ) { $this->date_column_name = $query_args['date_type']; } - if ( false === $data ) { - $this->initialize_queries(); + $this->initialize_queries(); - $data = (object) array( - 'totals' => (object) array(), - 'intervals' => (object) array(), - 'total' => 0, - 'pages' => 0, - 'page_no' => 0, - ); + $selections = $this->selected_columns( $query_args ); + $this->add_time_period_sql_params( $query_args, $table_name ); + $this->add_intervals_sql_params( $query_args, $table_name ); + $this->add_order_by_sql_params( $query_args ); + $where_time = $this->get_sql_clause( 'where_time' ); + $params = $this->get_limit_sql_params( $query_args ); + $coupon_join = "LEFT JOIN ( + SELECT + order_id, + SUM(discount_amount) AS discount_amount, + COUNT(DISTINCT coupon_id) AS coupons_count + FROM + {$wpdb->prefix}wc_order_coupon_lookup + GROUP BY + order_id + ) order_coupon_lookup + ON order_coupon_lookup.order_id = {$wpdb->prefix}wc_order_stats.order_id"; - $selections = $this->selected_columns( $query_args ); - $this->add_time_period_sql_params( $query_args, $table_name ); - $this->add_intervals_sql_params( $query_args, $table_name ); - $this->add_order_by_sql_params( $query_args ); - $where_time = $this->get_sql_clause( 'where_time' ); - $params = $this->get_limit_sql_params( $query_args ); - $coupon_join = "LEFT JOIN ( - SELECT - order_id, - SUM(discount_amount) AS discount_amount, - COUNT(DISTINCT coupon_id) AS coupons_count - FROM - {$wpdb->prefix}wc_order_coupon_lookup - GROUP BY - order_id - ) order_coupon_lookup - ON order_coupon_lookup.order_id = {$wpdb->prefix}wc_order_stats.order_id"; - - // Additional filtering for Orders report. - $this->orders_stats_sql_filter( $query_args ); - $this->total_query->add_sql_clause( 'select', $selections ); - $this->total_query->add_sql_clause( 'left_join', $coupon_join ); - $this->total_query->add_sql_clause( 'where_time', $where_time ); - $totals = $wpdb->get_results( - $this->total_query->get_query_statement(), - ARRAY_A - ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. - if ( null === $totals ) { - return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); - } - - // phpcs:ignore Generic.Commenting.Todo.TaskFound - // @todo Remove these assignements when refactoring segmenter classes to use query objects. - $totals_query = array( - 'from_clause' => $this->total_query->get_sql_clause( 'join' ), - 'where_time_clause' => $where_time, - 'where_clause' => $this->total_query->get_sql_clause( 'where' ), - ); - $intervals_query = array( - 'select_clause' => $this->get_sql_clause( 'select' ), - 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), - 'where_time_clause' => $where_time, - 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), - 'limit' => $this->get_sql_clause( 'limit' ), - ); - - $unique_products = $this->get_unique_product_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] ); - $totals[0]['products'] = $unique_products; - $segmenter = new Segmenter( $query_args, $this->report_columns ); - $unique_coupons = $this->get_unique_coupon_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] ); - $totals[0]['coupons_count'] = $unique_coupons; - $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); - $totals = (object) $this->cast_numbers( $totals[0] ); - - $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); - $this->interval_query->add_sql_clause( 'left_join', $coupon_join ); - $this->interval_query->add_sql_clause( 'where_time', $where_time ); - $db_intervals = $wpdb->get_col( - $this->interval_query->get_query_statement() - ); // phpcs:ignore cache ok, DB call ok, , unprepared SQL ok. - - $db_interval_count = count( $db_intervals ); - $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); - $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); - - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return $data; - } - - $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); - $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); - if ( '' !== $selections ) { - $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); - } - $intervals = $wpdb->get_results( - $this->interval_query->get_query_statement(), - ARRAY_A - ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. - - if ( null === $intervals ) { - return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); - } - - if ( isset( $intervals[0] ) ) { - $unique_coupons = $this->get_unique_coupon_count( $intervals_query['from_clause'], $intervals_query['where_time_clause'], $intervals_query['where_clause'], true ); - $intervals[0]['coupons_count'] = $unique_coupons; - } - - $data = (object) array( - 'totals' => $totals, - 'intervals' => $intervals, - 'total' => $expected_interval_count, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { - $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); - $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); - $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); - } else { - $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); - } - $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); - $this->create_interval_subtotals( $data->intervals ); - - $this->set_cached_data( $cache_key, $data ); + // Additional filtering for Orders report. + $this->orders_stats_sql_filter( $query_args ); + $this->total_query->add_sql_clause( 'select', $selections ); + $this->total_query->add_sql_clause( 'left_join', $coupon_join ); + $this->total_query->add_sql_clause( 'where_time', $where_time ); + $totals = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->total_query->get_query_statement(), + ARRAY_A + ); + if ( null === $totals ) { + return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } + // phpcs:ignore Generic.Commenting.Todo.TaskFound + // @todo Remove these assignements when refactoring segmenter classes to use query objects. + $totals_query = array( + 'from_clause' => $this->total_query->get_sql_clause( 'join' ), + 'where_time_clause' => $where_time, + 'where_clause' => $this->total_query->get_sql_clause( 'where' ), + ); + $intervals_query = array( + 'select_clause' => $this->get_sql_clause( 'select' ), + 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), + 'where_time_clause' => $where_time, + 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), + 'limit' => $this->get_sql_clause( 'limit' ), + ); + + $unique_products = $this->get_unique_product_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] ); + $totals[0]['products'] = $unique_products; + $segmenter = new Segmenter( $query_args, $this->report_columns ); + $unique_coupons = $this->get_unique_coupon_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] ); + $totals[0]['coupons_count'] = $unique_coupons; + $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); + $totals = (object) $this->cast_numbers( $totals[0] ); + + $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); + $this->interval_query->add_sql_clause( 'left_join', $coupon_join ); + $this->interval_query->add_sql_clause( 'where_time', $where_time ); + $db_intervals = $wpdb->get_col( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, , unprepared SQL ok. + $this->interval_query->get_query_statement() + ); + + $db_interval_count = count( $db_intervals ); + + $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); + $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); + if ( '' !== $selections ) { + $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); + } + $intervals = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, , unprepared SQL ok. + $this->interval_query->get_query_statement(), + ARRAY_A + ); + + if ( null === $intervals ) { + return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); + } + + if ( isset( $intervals[0] ) ) { + $unique_coupons = $this->get_unique_coupon_count( $intervals_query['from_clause'], $intervals_query['where_time_clause'], $intervals_query['where_clause'], true ); + $intervals[0]['coupons_count'] = $unique_coupons; + } + + $data->totals = $totals; + $data->intervals = $intervals; + + if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); + $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); + $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + } else { + $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); + } + $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); + return $data; } @@ -729,18 +728,4 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ) ); } - - /** - * Initialize query objects. - */ - protected function initialize_queries() { - $this->clear_all_clauses(); - unset( $this->subquery ); - $this->total_query = new SqlQuery( $this->context . '_total' ); - $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); - - $this->interval_query = new SqlQuery( $this->context . '_interval' ); - $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); - $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); - } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Query.php index 70311f89ec6..dd8a51343d4 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Query.php @@ -17,14 +17,23 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats; -defined( 'ABSPATH' ) || exit; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; -use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; +defined( 'ABSPATH' ) || exit; /** * API\Reports\Orders\Stats\Query */ -class Query extends ReportsQuery { +class Query extends GenericQuery { + + /** + * Specific query name. + * Will be used to load the `report-{name}` data store, + * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. + * + * @var string + */ + protected $name = 'orders-stats'; /** * Valid fields for Orders report. @@ -45,17 +54,4 @@ class Query extends ReportsQuery { ), ); } - - /** - * Get revenue data based on the current query vars. - * - * @return array - */ - public function get_data() { - $args = apply_filters( 'woocommerce_analytics_orders_stats_query_args', $this->get_query_vars() ); - - $data_store = \WC_Data_Store::load( 'report-orders-stats' ); - $results = $data_store->get_data( $args ); - return apply_filters( 'woocommerce_analytics_orders_stats_select_query', $results, $args ); - } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/PerformanceIndicators/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/PerformanceIndicators/Controller.php index ab98deb40f2..564cf206b6a 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/PerformanceIndicators/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/PerformanceIndicators/Controller.php @@ -452,10 +452,10 @@ class Controller extends GenericController { } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param array $stat_data Report data. - * @param WP_REST_Request $request Request object. + * @param array $stat_data Report data item as returned from Data Store. + * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $stat_data, $request ) { @@ -478,7 +478,7 @@ class Controller extends GenericController { /** * Prepare links for the request. * - * @param \Automattic\WooCommerce\Admin\API\Reports\Query $object Object data. + * @param object $object data. * @return array */ protected function prepare_links( $object ) { @@ -527,8 +527,13 @@ class Controller extends GenericController { */ public function format_data_value( $data, $stat, $report, $chart, $query_args ) { if ( 'jetpack/stats' === $report ) { + $index = false; + // Get the index of the field to tally. - $index = array_search( $chart, $data['general']->visits->fields, true ); + if ( isset( $data['general']->visits->fields ) && is_array( $data['general']->visits->fields ) ) { + $index = array_search( $chart, $data['general']->visits->fields, true ); + } + if ( ! $index ) { return null; } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Products/Controller.php index c6cc45feddd..60df83d6800 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Products/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Products/Controller.php @@ -9,8 +9,9 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Products; defined( 'ABSPATH' ) || exit; -use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; +use Automattic\WooCommerce\Admin\API\Reports\GenericController; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; @@ -41,51 +42,22 @@ class Controller extends GenericController implements ExportableInterface { ); /** - * Get items. + * Get data from `'products'` GenericQuery. * - * @param WP_REST_Request $request Request data. + * @override GenericController::get_datastore_data() * - * @return array|WP_Error + * @param array $query_args Query arguments. + * @return mixed Results from the data store. */ - public function get_items( $request ) { - $args = array(); - $registered = array_keys( $this->get_collection_params() ); - foreach ( $registered as $param_name ) { - if ( isset( $request[ $param_name ] ) ) { - if ( isset( $this->param_mapping[ $param_name ] ) ) { - $args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ]; - } else { - $args[ $param_name ] = $request[ $param_name ]; - } - } - } - - $reports = new Query( $args ); - $products_data = $reports->get_data(); - - $data = array(); - - foreach ( $products_data->data as $product_data ) { - $item = $this->prepare_item_for_response( $product_data, $request ); - if ( isset( $item->data['extended_info']['name'] ) ) { - $item->data['extended_info']['name'] = wp_strip_all_tags( $item->data['extended_info']['name'] ); - } - $data[] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $data, - (int) $products_data->total, - (int) $products_data->page_no, - (int) $products_data->pages - ); + protected function get_datastore_data( $query_args = array() ) { + $query = new GenericQuery( $query_args, 'products' ); + return $query->get_data(); } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param Array $report Report data. + * @param Array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ @@ -101,8 +73,36 @@ class Controller extends GenericController implements ExportableInterface { * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. + * + * @since 6.5.0 */ - return apply_filters( 'woocommerce_rest_prepare_report_products', $response, $report, $request ); + $filtered_response = apply_filters( 'woocommerce_rest_prepare_report_products', $response, $report, $request ); + if ( isset( $filtered_response->data['extended_info']['name'] ) ) { + $filtered_response->data['extended_info']['name'] = wp_strip_all_tags( $filtered_response->data['extended_info']['name'] ); + } + return $filtered_response; + } + + + /** + * Maps query arguments from the REST request. + * + * @param array $request Request array. + * @return array + */ + protected function prepare_reports_query( $request ) { + $args = array(); + $registered = array_keys( $this->get_collection_params() ); + foreach ( $registered as $param_name ) { + if ( isset( $request[ $param_name ] ) ) { + if ( isset( $this->param_mapping[ $param_name ] ) ) { + $args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ]; + } else { + $args[ $param_name ] = $request[ $param_name ]; + } + } + } + return $args; } /** diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Products/DataStore.php index 31ba4954873..fee11df494b 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Products/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Products/DataStore.php @@ -21,6 +21,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * + * @override ReportsDataStore::$table_name + * * @var string */ protected static $table_name = 'wc_order_product_lookup'; @@ -28,6 +30,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override ReportsDataStore::$cache_key + * * @var string */ protected $cache_key = 'products'; @@ -35,6 +39,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * + * @override ReportsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -79,12 +85,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override ReportsDataStore::$context + * * @var string */ protected $context = 'products'; /** * Assign report columns once full table name has been assigned. + * + * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); @@ -175,6 +185,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Maps ordering specified by the user to columns in the database/fields in the data. * + * @override ReportsDataStore::normalize_order_by() + * * @param string $order_by Sorting criterion. * @return string */ @@ -256,122 +268,137 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Returns the report data based on parameters supplied by the user. * + * @override ReportsDataStore::get_data() + * * @param array $query_args Query parameters. * @return stdClass|WP_Error Data. */ public function get_data( $query_args ) { + $data = parent::get_data( $query_args ); + + /* + * Do not cache extended info -- this is required to get the latest stock data. + * `include_extended_info` checks only `extended_info` key, + * so we don't need to bother about normalizing timestamps. + */ + $defaults = $this->get_default_query_vars(); + $query_args = wp_parse_args( $query_args, $defaults ); + $this->include_extended_info( $data->data, $query_args ); + + return $data; + } + + /** + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. + * + * @override ReportsDataStore::get_default_query_vars() + * + * @return array Query parameters. + */ + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['category_includes'] = array(); + $defaults['product_includes'] = array(); + $defaults['extended_info'] = false; + + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override ReportsDataStore::get_noncached_data() + * + * @see get_data + * @param array $query_args Query parameters. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_data( $query_args ) { global $wpdb; $table_name = self::get_db_table_name(); - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'date', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'fields' => '*', - 'category_includes' => array(), - 'product_includes' => array(), - 'extended_info' => false, + $this->initialize_queries(); + + $data = (object) array( + 'data' => array(), + 'total' => 0, + 'pages' => 0, + 'page_no' => 0, ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); + $selections = $this->selected_columns( $query_args ); + $included_products = $this->get_included_products_array( $query_args ); + $params = $this->get_limit_params( $query_args ); + $this->add_sql_query_params( $query_args ); - if ( false === $data ) { - $this->initialize_queries(); + if ( count( $included_products ) > 0 ) { + $filtered_products = array_diff( $included_products, array( '-1' ) ); + $total_results = count( $filtered_products ); + $total_pages = (int) ceil( $total_results / $params['per_page'] ); - $data = (object) array( - 'data' => array(), - 'total' => 0, - 'pages' => 0, - 'page_no' => 0, - ); - - $selections = $this->selected_columns( $query_args ); - $included_products = $this->get_included_products_array( $query_args ); - $params = $this->get_limit_params( $query_args ); - $this->add_sql_query_params( $query_args ); - - if ( count( $included_products ) > 0 ) { - $filtered_products = array_diff( $included_products, array( '-1' ) ); - $total_results = count( $filtered_products ); - $total_pages = (int) ceil( $total_results / $params['per_page'] ); - - if ( 'date' === $query_args['orderby'] ) { - $selections .= ", {$table_name}.date_created"; - } - - $fields = $this->get_fields( $query_args ); - $join_selections = $this->format_join_selections( $fields, array( 'product_id' ) ); - $ids_table = $this->get_ids_table( $included_products, 'product_id' ); - - $this->subquery->clear_sql_clause( 'select' ); - $this->subquery->add_sql_clause( 'select', $selections ); - $this->add_sql_clause( 'select', $join_selections ); - $this->add_sql_clause( 'from', '(' ); - $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); - $this->add_sql_clause( 'from', ") AS {$table_name}" ); - $this->add_sql_clause( - 'right_join', - "RIGHT JOIN ( {$ids_table} ) AS default_results - ON default_results.product_id = {$table_name}.product_id" - ); - $this->add_sql_clause( 'where', 'AND default_results.product_id != -1' ); - - $products_query = $this->get_query_statement(); - } else { - $count_query = "SELECT COUNT(*) FROM ( - {$this->subquery->get_query_statement()} - ) AS tt"; - $db_records_count = (int) $wpdb->get_var( - $count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - ); - - $total_results = $db_records_count; - $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); - - if ( ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) ) { - return $data; - } - - $this->subquery->clear_sql_clause( 'select' ); - $this->subquery->add_sql_clause( 'select', $selections ); - $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $products_query = $this->subquery->get_query_statement(); + if ( 'date' === $query_args['orderby'] ) { + $selections .= ", {$table_name}.date_created"; } - $product_data = $wpdb->get_results( - $products_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - ARRAY_A + $fields = $this->get_fields( $query_args ); + $join_selections = $this->format_join_selections( $fields, array( 'product_id' ) ); + $ids_table = $this->get_ids_table( $included_products, 'product_id' ); + + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->add_sql_clause( 'select', $join_selections ); + $this->add_sql_clause( 'from', '(' ); + $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); + $this->add_sql_clause( 'from', ") AS {$table_name}" ); + $this->add_sql_clause( + 'right_join', + "RIGHT JOIN ( {$ids_table} ) AS default_results + ON default_results.product_id = {$table_name}.product_id" + ); + $this->add_sql_clause( 'where', 'AND default_results.product_id != -1' ); + + $products_query = $this->get_query_statement(); + } else { + $count_query = "SELECT COUNT(*) FROM ( + {$this->subquery->get_query_statement()} + ) AS tt"; + $db_records_count = (int) $wpdb->get_var( + $count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ); - if ( null === $product_data ) { + $total_results = $db_records_count; + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); + + if ( ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) ) { return $data; } - $product_data = array_map( array( $this, 'cast_numbers' ), $product_data ); - $data = (object) array( - 'data' => $product_data, - 'total' => $total_results, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - $this->set_cached_data( $cache_key, $data ); + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $products_query = $this->subquery->get_query_statement(); } - $this->include_extended_info( $data->data, $query_args ); + $product_data = $wpdb->get_results( + $products_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ARRAY_A + ); + + if ( null === $product_data ) { + return $data; + } + + $product_data = array_map( array( $this, 'cast_numbers' ), $product_data ); + $data = (object) array( + 'data' => $product_data, + 'total' => $total_results, + 'pages' => $total_pages, + 'page_no' => (int) $query_args['page'], + ); return $data; } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Products/Query.php index 63810eff234..e7bbed3d199 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Products/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Products/Query.php @@ -22,24 +22,34 @@ use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Products\Query + * + * @deprecated 9.3.0 Products\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * + * @deprecated 9.3.0 Products\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array(); } /** * Get product data based on the current query vars. * + * @deprecated 9.3.0 Products\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_products_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-products' ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Controller.php index bfb24195543..acf047a37f9 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Controller.php @@ -9,8 +9,8 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats; defined( 'ABSPATH' ) || exit; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; -use Automattic\WooCommerce\Admin\API\Reports\ParameterException; use WP_REST_Request; use WP_REST_Response; @@ -48,12 +48,25 @@ class Controller extends GenericStatsController { } /** - * Get all reports. + * Get data from `'products-stats'` GenericQuery. * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. */ - public function get_items( $request ) { + protected function get_datastore_data( $query_args = array() ) { + $query = new GenericQuery( $query_args, 'products-stats' ); + return $query->get_data(); + } + + /** + * Maps query arguments from the REST request to be used to query the datastore. + * + * @param \WP_REST_Request $request Full request object. + * @return array Simplified array of params. + */ + protected function prepare_reports_query( $request ) { $query_args = array( 'fields' => array( 'items_sold', @@ -75,36 +88,13 @@ class Controller extends GenericStatsController { } } - $query = new Query( $query_args ); - try { - $report_data = $query->get_data(); - } catch ( ParameterException $e ) { - return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); - } - - $out_data = array( - 'totals' => get_object_vars( $report_data->totals ), - 'intervals' => array(), - ); - - foreach ( $report_data->intervals as $interval_data ) { - $item = $this->prepare_item_for_response( $interval_data, $request ); - $out_data['intervals'][] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $out_data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); + return $query_args; } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param array $report Report data. + * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ @@ -255,15 +245,6 @@ class Controller extends GenericStatsController { ), 'validate_callback' => 'rest_validate_request_arg', ); - $params['fields'] = array( - 'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ), - 'type' => 'array', - 'sanitize_callback' => 'wp_parse_slug_list', - 'validate_callback' => 'rest_validate_request_arg', - 'items' => array( - 'type' => 'string', - ), - ); return $params; } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php index c1e75d09a7a..23fe869dbfb 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php @@ -8,18 +8,22 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore; +use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; -use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; +use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Products\Stats\DataStore. */ class DataStore extends ProductsDataStore implements DataStoreInterface { + use StatsDataStoreTrait; /** * Mapping columns to data type to return correct response types. * + * @override ProductsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -36,6 +40,8 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override ProductsDataStore::$cache_key + * * @var string */ protected $cache_key = 'products_stats'; @@ -43,12 +49,16 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override ProductsDataStore::$context + * * @var string */ protected $context = 'products_stats'; /** * Assign report columns once full table name has been assigned. + * + * @override ProductsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); @@ -99,138 +109,141 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); } + /** + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. + * + * @override ProductsDataStore::get_default_query_vars() + * + * @return array Query parameters. + */ + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['interval'] = 'week'; + unset( $defaults['extended_info'] ); + + return $defaults; + } + /** * Returns the report data based on parameters supplied by the user. * - * @since 3.5.0 + * @override ProductsDataStore::get_data() + * * @param array $query_args Query parameters. * @return stdClass|WP_Error Data. */ public function get_data( $query_args ) { + // Do not include extended info like `ProductsDataStore` does. + return ReportsDataStore::get_data( $query_args ); + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override ProductsDataStore::get_noncached_data() + * + * @see get_data + * @see get_noncached_stats_data + * @param array $query_args Query parameters. + * @param array $params Query limit parameters. + * @param stdClass $data Reference to the data object to fill. + * @param int $expected_interval_count Number of expected intervals. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'date', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'fields' => '*', - 'category_includes' => array(), - 'interval' => 'week', - 'product_includes' => array(), + $this->initialize_queries(); + + $selections = $this->selected_columns( $query_args ); + + $this->update_sql_query_params( $query_args ); + $this->get_limit_sql_params( $query_args ); + $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); + + $db_intervals = $wpdb->get_col( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->interval_query->get_query_statement() ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); + $db_interval_count = count( $db_intervals ); - if ( false === $data ) { - $this->initialize_queries(); + $intervals = array(); + $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); + $this->total_query->add_sql_clause( 'select', $selections ); + $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); - $selections = $this->selected_columns( $query_args ); - $params = $this->get_limit_params( $query_args ); + $totals = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->total_query->get_query_statement(), + ARRAY_A + ); - $this->update_sql_query_params( $query_args ); - $this->get_limit_sql_params( $query_args ); - $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); + // phpcs:ignore Generic.Commenting.Todo.TaskFound + // @todo remove these assignements when refactoring segmenter classes to use query objects. + $totals_query = array( + 'from_clause' => $this->total_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->total_query->get_sql_clause( 'where' ), + ); + $intervals_query = array( + 'select_clause' => $this->get_sql_clause( 'select' ), + 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), + 'order_by' => $this->get_sql_clause( 'order_by' ), + 'limit' => $this->get_sql_clause( 'limit' ), + ); + $segmenter = new Segmenter( $query_args, $this->report_columns ); + $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); - $db_intervals = $wpdb->get_col( - $this->interval_query->get_query_statement() - ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - - $db_interval_count = count( $db_intervals ); - $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); - $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return array(); - } - - $intervals = array(); - $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); - $this->total_query->add_sql_clause( 'select', $selections ); - $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); - - $totals = $wpdb->get_results( - $this->total_query->get_query_statement(), - ARRAY_A - ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - - // @todo remove these assignements when refactoring segmenter classes to use query objects. - $totals_query = array( - 'from_clause' => $this->total_query->get_sql_clause( 'join' ), - 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), - 'where_clause' => $this->total_query->get_sql_clause( 'where' ), - ); - $intervals_query = array( - 'select_clause' => $this->get_sql_clause( 'select' ), - 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), - 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), - 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), - 'order_by' => $this->get_sql_clause( 'order_by' ), - 'limit' => $this->get_sql_clause( 'limit' ), - ); - $segmenter = new Segmenter( $query_args, $this->report_columns ); - $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); - - if ( null === $totals ) { - return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); - } - - $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); - if ( '' !== $selections ) { - $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); - } - - $intervals = $wpdb->get_results( - $this->interval_query->get_query_statement(), - ARRAY_A - ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - - if ( null === $intervals ) { - return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); - } - - $totals = (object) $this->cast_numbers( $totals[0] ); - - $data = (object) array( - 'totals' => $totals, - 'intervals' => $intervals, - 'total' => $expected_interval_count, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { - $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); - $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); - $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); - } else { - $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); - } - $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); - $this->create_interval_subtotals( $data->intervals ); - - $this->set_cached_data( $cache_key, $data ); + if ( null === $totals ) { + return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } + $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); + if ( '' !== $selections ) { + $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); + } + + $intervals = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->interval_query->get_query_statement(), + ARRAY_A + ); + + if ( null === $intervals ) { + return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); + } + + $totals = (object) $this->cast_numbers( $totals[0] ); + + $data->totals = $totals; + $data->intervals = $intervals; + + if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); + $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); + $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + } else { + $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); + } + $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); + return $data; } /** * Normalizes order_by clause to match to SQL query. * + * @override ProductsDataStore::normalize_order_by() + * * @param string $order_by Order by option requeste by user. * @return string */ @@ -241,18 +254,4 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { return $order_by; } - - /** - * Initialize query objects. - */ - protected function initialize_queries() { - $this->clear_all_clauses(); - unset( $this->subquery ); - $this->total_query = new SqlQuery( $this->context . '_total' ); - $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); - - $this->interval_query = new SqlQuery( $this->context . '_interval' ); - $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); - $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); - } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Query.php index dc089601772..d6245c8f732 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Query.php @@ -22,24 +22,34 @@ use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Products\Stats\Query + * + * @deprecated 9.3.0 Products\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * + * @deprecated 9.3.0 Products\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array(); } /** * Get product data based on the current query vars. * + * @deprecated 9.3.0 Products\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_products_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-products-stats' ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Query.php index c287159beeb..a6d2a346de9 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Query.php @@ -9,15 +9,32 @@ defined( 'ABSPATH' ) || exit; /** * Admin\API\Reports\Query + * + * @deprecated 9.3.0 Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ abstract class Query extends \WC_Object_Query { + /** + * Create a new query. + * + * @deprecated 9.3.0 Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * + * @param array $args Criteria to query on in a format similar to WP_Query. + */ + public function __construct( $args = array() ) { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + parent::__construct( $args ); + } + /** * Get report data matching the current query vars. * + * @deprecated 9.3.0 Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array|object of WC_Product objects */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); /* translators: %s: Method name */ return new \WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass.", 'woocommerce' ), __METHOD__ ), array( 'status' => 405 ) ); } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Revenue/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Revenue/Query.php index 6a36261e328..f5f4bb5bd90 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Revenue/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Revenue/Query.php @@ -16,12 +16,15 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Revenue; defined( 'ABSPATH' ) || exit; -use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; - /** * API\Reports\Revenue\Query + * + * This query uses inconsistent names: + * - `report-revenue-stats` data store + * - `woocommerce_analytics_revenue_*` filters + * So, for backward compatibility, we cannot use GenericQuery. */ -class Query extends ReportsQuery { +class Query extends \WC_Object_Query { /** * Valid fields for Revenue report. diff --git a/plugins/woocommerce/src/Admin/API/Reports/Revenue/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Revenue/Stats/Controller.php index 077fb917305..826153a3b60 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Revenue/Stats/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Revenue/Stats/Controller.php @@ -13,7 +13,6 @@ use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use Automattic\WooCommerce\Admin\API\Reports\Revenue\Query as RevenueQuery; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits; -use Automattic\WooCommerce\Admin\API\Reports\ParameterException; use WP_REST_Request; use WP_REST_Response; @@ -60,37 +59,16 @@ class Controller extends GenericStatsController implements ExportableInterface { } /** - * Get all reports. + * Get data from RevenueQuery. * - * @param WP_REST_Request $request Request data. - * @return WP_REST_Response|WP_Error + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. */ - public function get_items( $request ) { - $query_args = $this->prepare_reports_query( $request ); - $reports_revenue = new RevenueQuery( $query_args ); - try { - $report_data = $reports_revenue->get_data(); - } catch ( ParameterException $e ) { - return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); - } - - $out_data = array( - 'totals' => get_object_vars( $report_data->totals ), - 'intervals' => array(), - ); - - foreach ( $report_data->intervals as $interval_data ) { - $item = $this->prepare_item_for_response( $interval_data, $request ); - $out_data['intervals'][] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $out_data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); + protected function get_datastore_data( $query_args = array() ) { + $query = new RevenueQuery( $query_args ); + return $query->get_data(); } /** @@ -112,9 +90,9 @@ class Controller extends GenericStatsController implements ExportableInterface { } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param array $report Report data. + * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ @@ -279,6 +257,7 @@ class Controller extends GenericStatsController implements ExportableInterface { ), 'validate_callback' => 'rest_validate_request_arg', ); + unset( $params['fields'] ); return $params; } diff --git a/plugins/woocommerce/src/Admin/API/Reports/StatsDataStoreTrait.php b/plugins/woocommerce/src/Admin/API/Reports/StatsDataStoreTrait.php new file mode 100644 index 00000000000..f05204336aa --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/Reports/StatsDataStoreTrait.php @@ -0,0 +1,120 @@ +class MyStatsDataStore extends DataStore implements DataStoreInterface { + * // Use the trait. + * use StatsDataStoreTrait; + * // Provide all the necessary properties and methods for a regular DataStore. + * // ... + * /** + * * Return your results with the help of the interval & total methods and queries. + * * @return stdClass|WP_Error $data filled with your results. + * */ + * public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { + * $this->initialize_queries(); + * // Do your magic ... + * // ... with a help of things like: + * $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); + * $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); + * + * $totals = $wpdb->get_results( + * $this->total_query->get_query_statement(), + * ARRAY_A + * ); + * + * $intervals = $wpdb->get_results( + * $this->interval_query->get_query_statement(), + * ARRAY_A + * ); + * + * $data->totals = (object) $this->cast_numbers( $totals[0] ); + * $data->intervals = $intervals; + * + * if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + * $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); + * $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); + * $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + * } else { + * $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); + * } + * + * return $data; + * } + * } + * + * + * @see DataStore + */ +trait StatsDataStoreTrait { + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + unset( $this->subquery ); + $table_name = self::get_db_table_name(); + + $this->total_query = new SqlQuery( $this->context . '_total' ); + $this->total_query->add_sql_clause( 'from', $table_name ); + + $this->interval_query = new SqlQuery( $this->context . '_interval' ); + $this->interval_query->add_sql_clause( 'from', $table_name ); + $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); + } + + /** + * Returns the stats report data based on normalized parameters. + * Prepares the basic intervals and object structure + * Will be called by `get_data` if there is no data in cache. + * Will call `get_noncached_stats_data` to fetch the actual data. + * + * @see get_data + * @param array $query_args Query parameters. + * @return stdClass|WP_Error Data object, or error. + */ + public function get_noncached_data( $query_args ) { + $params = $this->get_limit_params( $query_args ); + $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); + $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); + + // Default, empty data object. + $data = (object) array( + 'totals' => null, + 'intervals' => array(), + 'total' => $expected_interval_count, + 'pages' => $total_pages, + 'page_no' => (int) $query_args['page'], + ); + // If the requested page is out off range, return the default empty object. + if ( $query_args['page'] >= 1 && $query_args['page'] <= $total_pages ) { + // Fetch the actual data. + $data = $this->get_noncached_stats_data( $query_args, $params, $data, $expected_interval_count ); + + if ( ! is_wp_error( $data ) && is_array( $data->intervals ) ) { + $this->create_interval_subtotals( $data->intervals ); + } + } + + return $data; + } +} diff --git a/plugins/woocommerce/src/Admin/API/Reports/Stock/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Stock/Controller.php index 72cb2f66c74..11524a422f0 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Stock/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Stock/Controller.php @@ -276,9 +276,9 @@ class Controller extends GenericController implements ExportableInterface { } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param WC_Product $product Report data. + * @param WC_Product $product Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ diff --git a/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/Controller.php index 6e51ac2dd98..3cf4d2ee8c2 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/Controller.php @@ -47,9 +47,9 @@ class Controller extends \WC_REST_Reports_Controller { } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param WC_Product $report Report data. + * @param WC_Product $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ diff --git a/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/DataStore.php index 3af2c08162b..9af9159a1a0 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/DataStore.php @@ -18,6 +18,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Get stock counts for the whole store. * + * @override ReportsDataStore::get_data() + * * @param array $query Not used for the stock stats data store, but needed for the interface. * @return array Array of counts. */ diff --git a/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/Query.php index f485a96b2b7..b4886e62340 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/Query.php @@ -10,12 +10,11 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats; defined( 'ABSPATH' ) || exit; -use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; - /** * API\Reports\Stock\Stats\Query + * This query takes no arguments, so we do not inherit from GenericQuery. */ -class Query extends ReportsQuery { +class Query extends \WC_Object_Query { /** * Get product data based on the current query vars. diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php index 762056e1f5d..9d6cbd54364 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php @@ -9,9 +9,10 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes; defined( 'ABSPATH' ) || exit; -use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits; +use Automattic\WooCommerce\Admin\API\Reports\GenericController; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; @@ -34,6 +35,19 @@ class Controller extends GenericController implements ExportableInterface { */ protected $rest_base = 'reports/taxes'; + /** + * Get data from `'taxes'` GenericQuery. + * + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. + */ + protected function get_datastore_data( $query_args = array() ) { + $query = new GenericQuery( $query_args, 'taxes' ); + return $query->get_data(); + } + /** * Maps query arguments from the REST request. * @@ -55,41 +69,17 @@ class Controller extends GenericController implements ExportableInterface { } /** - * Get all reports. + * Prepare a report data item for serialization. * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error - */ - public function get_items( $request ) { - $query_args = $this->prepare_reports_query( $request ); - $taxes_query = new Query( $query_args ); - $report_data = $taxes_query->get_data(); - - $data = array(); - - foreach ( $report_data->data as $tax_data ) { - $item = $this->prepare_item_for_response( (object) $tax_data, $request ); - $data[] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); - } - - /** - * Prepare a report object for serialization. - * - * @param stdClass $report Report data. + * @param mixed $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); + + // Map to `object` for backwards compatibility. + $report = (object) $report; $response->add_links( $this->prepare_links( $report ) ); /** diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php index e1de6f64164..d808b8170fb 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php @@ -21,6 +21,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * + * @override ReportsDataStore::$table_name + * * @var string */ protected static $table_name = 'wc_order_tax_lookup'; @@ -28,6 +30,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override ReportsDataStore::$cache_key + * * @var string */ protected $cache_key = 'taxes'; @@ -35,6 +39,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * + * @override ReportsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -53,12 +59,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override ReportsDataStore::$context + * * @var string */ protected $context = 'taxes'; /** * Assign report columns once full table name has been assigned. + * + * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { global $wpdb; @@ -138,101 +148,97 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. + * @override ReportsDataStore::get_default_query_vars() + * + * @return array Query parameters. */ - public function get_data( $query_args ) { + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['orderby'] = 'tax_rate_id'; + $defaults['taxes'] = array(); + + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override ReportsDataStore::get_noncached_data() + * + * @see get_data + * @param array $query_args Query parameters. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_data( $query_args ) { global $wpdb; - $table_name = self::get_db_table_name(); + $this->initialize_queries(); - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'tax_rate_id', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'fields' => '*', - 'taxes' => array(), + $data = (object) array( + 'data' => array(), + 'total' => 0, + 'pages' => 0, + 'page_no' => 0, ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); + $this->add_sql_query_params( $query_args ); + $params = $this->get_limit_params( $query_args ); - if ( false === $data ) { - $this->initialize_queries(); - - $data = (object) array( - 'data' => array(), - 'total' => 0, - 'pages' => 0, - 'page_no' => 0, + if ( isset( $query_args['taxes'] ) && is_array( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { + $total_results = count( $query_args['taxes'] ); + $total_pages = (int) ceil( $total_results / $params['per_page'] ); + } else { + $db_records_count = (int) $wpdb->get_var( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- cache ok, DB call ok, unprepared SQL ok. + "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) AS tt" ); - $this->add_sql_query_params( $query_args ); - $params = $this->get_limit_params( $query_args ); + $total_results = $db_records_count; + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); - if ( isset( $query_args['taxes'] ) && is_array( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { - $total_results = count( $query_args['taxes'] ); - $total_pages = (int) ceil( $total_results / $params['per_page'] ); - } else { - $db_records_count = (int) $wpdb->get_var( - "SELECT COUNT(*) FROM ( - {$this->subquery->get_query_statement()} - ) AS tt" - ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - - $total_results = $db_records_count; - $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); - - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return $data; - } - } - - $this->subquery->clear_sql_clause( 'select' ); - $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); - $this->subquery->add_sql_clause( 'group_by', ", {$wpdb->prefix}woocommerce_order_items.order_item_name, {$wpdb->prefix}woocommerce_order_itemmeta.meta_value" ); - $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - - $taxes_query = $this->subquery->get_query_statement(); - - $tax_data = $wpdb->get_results( - $taxes_query, - ARRAY_A - ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - - if ( null === $tax_data ) { + if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } - - $tax_data = array_map( array( $this, 'cast_numbers' ), $tax_data ); - $data = (object) array( - 'data' => $tax_data, - 'total' => $total_results, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - $this->set_cached_data( $cache_key, $data ); } + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); + $this->subquery->add_sql_clause( 'group_by', ", {$wpdb->prefix}woocommerce_order_items.order_item_name, {$wpdb->prefix}woocommerce_order_itemmeta.meta_value" ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + + $taxes_query = $this->subquery->get_query_statement(); + + $tax_data = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $taxes_query, + ARRAY_A + ); + + if ( null === $tax_data ) { + return $data; + } + + $tax_data = array_map( array( $this, 'cast_numbers' ), $tax_data ); + $data = (object) array( + 'data' => $tax_data, + 'total' => $total_results, + 'pages' => $total_pages, + 'page_no' => (int) $query_args['page'], + ); + return $data; } /** * Maps ordering specified by the user to columns in the database/fields in the data. * + * @override ReportsDataStore::normalize_order_by() + * * @param string $order_by Sorting criterion. * @return string */ diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Query.php index 3323b65845e..bcc5ced65d7 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Query.php @@ -21,24 +21,34 @@ use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Taxes\Query + * + * @deprecated 9.3.0 Taxes\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Taxes report. * + * @deprecated 9.3.0 Taxes\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array(); } /** * Get product data based on the current query vars. * + * @deprecated 9.3.0 Taxes\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_taxes_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-taxes' ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Controller.php index 426ea8c2432..f551d54d04e 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Controller.php @@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats; defined( 'ABSPATH' ) || exit; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use WP_REST_Request; use WP_REST_Response; @@ -83,47 +84,30 @@ class Controller extends GenericStatsController { } /** - * Get all reports. + * Get data from `'taxes-stats'` GenericQuery. * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. */ - public function get_items( $request ) { - $query_args = $this->prepare_reports_query( $request ); - $taxes_query = new Query( $query_args ); - $report_data = $taxes_query->get_data(); - - $out_data = array( - 'totals' => get_object_vars( $report_data->totals ), - 'intervals' => array(), - ); - - foreach ( $report_data->intervals as $interval_data ) { - $item = $this->prepare_item_for_response( (object) $interval_data, $request ); - $out_data['intervals'][] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $out_data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); + protected function get_datastore_data( $query_args = array() ) { + $query = new GenericQuery( $query_args, 'taxes-stats' ); + return $query->get_data(); } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param stdClass $report Report data. + * @param mixed $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { - $data = get_object_vars( $report ); - - $response = parent::prepare_item_for_response( $data, $request ); + $response = parent::prepare_item_for_response( $report, $request ); + // Map to `object` for backwards compatibility. + $report = (object) $report; /** * Filter a report returned from the API. * @@ -226,15 +210,6 @@ class Controller extends GenericStatsController { ), 'validate_callback' => 'rest_validate_request_arg', ); - $params['fields'] = array( - 'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ), - 'type' => 'array', - 'sanitize_callback' => 'wp_parse_slug_list', - 'validate_callback' => 'rest_validate_request_arg', - 'items' => array( - 'type' => 'string', - ), - ); return $params; } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/DataStore.php index 486bfaee488..2173f6b859c 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/DataStore.php @@ -10,16 +10,19 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; -use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; +use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Taxes\Stats\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { + use StatsDataStoreTrait; /** * Table used to get the data. * + * @override ReportsDataStore::$table_name + * * @var string */ protected static $table_name = 'wc_order_tax_lookup'; @@ -27,6 +30,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override ReportsDataStore::$cache_key + * * @var string */ protected $cache_key = 'taxes_stats'; @@ -34,6 +39,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * + * @override ReportsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -47,12 +54,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override ReportsDataStore::$context + * * @var string */ protected $context = 'taxes_stats'; /** * Assign report columns once full table name has been assigned. + * + * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); @@ -107,12 +118,12 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public static function get_taxes( $args ) { global $wpdb; $query = " - SELECT - tax_rate_id, - tax_rate_country, - tax_rate_state, - tax_rate_name, - tax_rate_priority + SELECT + tax_rate_id, + tax_rate_country, + tax_rate_state, + tax_rate_name, + tax_rate_priority FROM {$wpdb->prefix}woocommerce_tax_rates "; if ( ! empty( $args['include'] ) ) { @@ -126,146 +137,116 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. + * @override ReportsDataStore::get_default_query_vars() + * + * @return array Query parameters. */ - public function get_data( $query_args ) { + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['orderby'] = 'tax_rate_id'; + $defaults['taxes'] = array(); + + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override ReportsDataStore::get_noncached_data() + * + * @see get_data + * @see get_noncached_stats_data + * @param array $query_args Query parameters. + * @param array $params Query limit parameters. + * @param stdClass $data Reference to the data object to fill. + * @param int $expected_interval_count Number of expected intervals. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'tax_rate_id', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'fields' => '*', - 'taxes' => array(), + $this->initialize_queries(); + + $selections = $this->selected_columns( $query_args ); + $order_stats_join = "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; + $this->update_sql_query_params( $query_args ); + $this->interval_query->add_sql_clause( 'join', $order_stats_join ); + + $db_intervals = $wpdb->get_col( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->interval_query->get_query_statement() ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); + $db_interval_count = count( $db_intervals ); - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); + $this->total_query->add_sql_clause( 'select', $selections ); + $this->total_query->add_sql_clause( 'join', $order_stats_join ); + $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); - if ( false === $data ) { - $this->initialize_queries(); + $totals = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->total_query->get_query_statement(), + ARRAY_A + ); - $data = (object) array( - 'totals' => (object) array(), - 'intervals' => (object) array(), - 'total' => 0, - 'pages' => 0, - 'page_no' => 0, - ); - - $selections = $this->selected_columns( $query_args ); - $params = $this->get_limit_params( $query_args ); - $order_stats_join = "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; - $this->update_sql_query_params( $query_args ); - $this->interval_query->add_sql_clause( 'join', $order_stats_join ); - - $db_intervals = $wpdb->get_col( - $this->interval_query->get_query_statement() - ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - $db_interval_count = count( $db_intervals ); - $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); - $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); - - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return $data; - } - $this->total_query->add_sql_clause( 'select', $selections ); - $this->total_query->add_sql_clause( 'join', $order_stats_join ); - $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); - - $totals = $wpdb->get_results( - $this->total_query->get_query_statement(), - ARRAY_A - ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - - if ( null === $totals ) { - return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); - } - - // @todo remove these assignements when refactoring segmenter classes to use query objects. - $totals_query = array( - 'from_clause' => $this->total_query->get_sql_clause( 'join' ), - 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), - 'where_clause' => $this->total_query->get_sql_clause( 'where' ), - ); - $intervals_query = array( - 'select_clause' => $this->get_sql_clause( 'select' ), - 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), - 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), - 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), - ); - $segmenter = new Segmenter( $query_args, $this->report_columns ); - $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); - - $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); - - if ( '' !== $selections ) { - $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); - } - - $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); - $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - - $intervals = $wpdb->get_results( - $this->interval_query->get_query_statement(), - ARRAY_A - ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - - if ( null === $intervals ) { - return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching tax data failed.', 'woocommerce' ) ); - } - - $totals = (object) $this->cast_numbers( $totals[0] ); - - $data = (object) array( - 'totals' => $totals, - 'intervals' => $intervals, - 'total' => $expected_interval_count, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { - $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); - $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); - $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); - } else { - $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); - } - $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); - $this->create_interval_subtotals( $data->intervals ); - $this->set_cached_data( $cache_key, $data ); + if ( null === $totals ) { + return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } + + // phpcs:ignore Generic.Commenting.Todo.TaskFound + // @todo remove these assignements when refactoring segmenter classes to use query objects. + $totals_query = array( + 'from_clause' => $this->total_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->total_query->get_sql_clause( 'where' ), + ); + $intervals_query = array( + 'select_clause' => $this->get_sql_clause( 'select' ), + 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), + ); + $segmenter = new Segmenter( $query_args, $this->report_columns ); + $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); + + $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); + + if ( '' !== $selections ) { + $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); + } + + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); + $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + + $intervals = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. + $this->interval_query->get_query_statement(), + ARRAY_A + ); + + if ( null === $intervals ) { + return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching tax data failed.', 'woocommerce' ) ); + } + + $totals = (object) $this->cast_numbers( $totals[0] ); + + $data->totals = $totals; + $data->intervals = $intervals; + + if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); + $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); + $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + } else { + $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); + } + $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); return $data; } - - /** - * Initialize query objects. - */ - protected function initialize_queries() { - $this->clear_all_clauses(); - unset( $this->subquery ); - $this->total_query = new SqlQuery( $this->context . '_total' ); - $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); - - $this->interval_query = new SqlQuery( $this->context . '_interval' ); - $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); - $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); - } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Query.php index 8ff96321bd9..98301fc1b82 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Query.php @@ -22,24 +22,34 @@ use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Taxes\Stats\Query + * + * @deprecated 9.3.0 Taxes\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Taxes report. * + * @deprecated 9.3.0 Taxes\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array(); } /** * Get tax stats data based on the current query vars. * + * @deprecated 9.3.0 Taxes\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_taxes_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-taxes-stats' ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Variations/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Variations/Controller.php index 39980256db7..43c499e2636 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Variations/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Variations/Controller.php @@ -9,17 +9,24 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Variations; defined( 'ABSPATH' ) || exit; -use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits; +use Automattic\WooCommerce\Admin\API\Reports\GenericController; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; +use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; + /** * REST API Reports products controller class. * * @internal - * @extends ReportsController + * @extends GenericController */ -class Controller extends ReportsController implements ExportableInterface { +class Controller extends GenericController implements ExportableInterface { + + // The controller does not use this trait. It's here for API backward compatibility. + use OrderAwareControllerTrait; + /** * Exportable traits. */ @@ -39,16 +46,56 @@ class Controller extends ReportsController implements ExportableInterface { */ protected $param_mapping = array( 'variations' => 'variation_includes', + 'products' => 'product_includes', ); /** - * Get items. + * Get data from `'variations'` GenericQuery. * - * @param WP_REST_Request $request Request data. + * @override GenericController::get_datastore_data() * - * @return array|WP_Error + * @param array $query_args Query arguments. + * @return mixed Results from the data store. */ - public function get_items( $request ) { + protected function get_datastore_data( $query_args = array() ) { + $query = new GenericQuery( $query_args, 'variations' ); + return $query->get_data(); + } + + /** + * Prepare a report data item for serialization. + * + * @param array $report Report data item as returned from Data Store. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response + */ + public function prepare_item_for_response( $report, $request ) { + // Wrap the data in a response object. + $response = parent::prepare_item_for_response( $report, $request ); + + $response->add_links( $this->prepare_links( $report ) ); + + /** + * Filter a report returned from the API. + * + * Allows modification of the report data right before it is returned. + * + * @since 6.5.0 + * + * @param WP_REST_Response $response The response object. + * @param object $report The original report object. + * @param WP_REST_Request $request Request used to generate the response. + */ + return apply_filters( 'woocommerce_rest_prepare_report_variations', $response, $report, $request ); + } + + /** + * Maps query arguments from the REST request. + * + * @param array $request Request array. + * @return array + */ + protected function prepare_reports_query( $request ) { $args = array(); /** * Experimental: Filter the list of parameters provided when querying data from the data store. @@ -56,6 +103,8 @@ class Controller extends ReportsController implements ExportableInterface { * @ignore * * @param array $collection_params List of parameters. + * + * @since 6.5.0 */ $collection_params = apply_filters( 'experimental_woocommerce_analytics_variations_collection_params', @@ -71,54 +120,7 @@ class Controller extends ReportsController implements ExportableInterface { } } } - - $reports = new Query( $args ); - $products_data = $reports->get_data(); - - $data = array(); - - foreach ( $products_data->data as $product_data ) { - $item = $this->prepare_item_for_response( $product_data, $request ); - $data[] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $data, - (int) $products_data->total, - (int) $products_data->page_no, - (int) $products_data->pages - ); - } - - /** - * Prepare a report object for serialization. - * - * @param array $report Report data. - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response - */ - public function prepare_item_for_response( $report, $request ) { - $data = $report; - - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $data = $this->add_additional_fields_to_object( $data, $request ); - $data = $this->filter_response_by_context( $data, $context ); - - // Wrap the data in a response object. - $response = rest_ensure_response( $data ); - $response->add_links( $this->prepare_links( $report ) ); - - /** - * Filter a report returned from the API. - * - * Allows modification of the report data right before it is returned. - * - * @param WP_REST_Response $response The response object. - * @param object $report The original report object. - * @param WP_REST_Request $request Request used to generate the response. - */ - return apply_filters( 'woocommerce_rest_prepare_report_variations', $response, $report, $request ); + return $args; } /** @@ -243,38 +245,15 @@ class Controller extends ReportsController implements ExportableInterface { * @return array */ public function get_collection_params() { - $params = array(); - $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); - $params['page'] = array( - 'description' => __( 'Current page of the collection.', 'woocommerce' ), - 'type' => 'integer', - 'default' => 1, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - 'minimum' => 1, + $params = parent::get_collection_params(); + $params['orderby']['enum'] = array( + 'date', + 'net_revenue', + 'orders_count', + 'items_sold', + 'sku', ); - $params['per_page'] = array( - 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ), - 'type' => 'integer', - 'default' => 10, - 'minimum' => 1, - 'maximum' => 100, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['after'] = array( - 'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ), - 'type' => 'string', - 'format' => 'date-time', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['before'] = array( - 'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ), - 'type' => 'string', - 'format' => 'date-time', - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['match'] = array( + $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', @@ -284,27 +263,7 @@ class Controller extends ReportsController implements ExportableInterface { ), 'validate_callback' => 'rest_validate_request_arg', ); - $params['order'] = array( - 'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ), - 'type' => 'string', - 'default' => 'desc', - 'enum' => array( 'asc', 'desc' ), - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['orderby'] = array( - 'description' => __( 'Sort collection by object attribute.', 'woocommerce' ), - 'type' => 'string', - 'default' => 'date', - 'enum' => array( - 'date', - 'net_revenue', - 'orders_count', - 'items_sold', - 'sku', - ), - 'validate_callback' => 'rest_validate_request_arg', - ); - $params['product_includes'] = array( + $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified parent product(s).', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -314,7 +273,7 @@ class Controller extends ReportsController implements ExportableInterface { 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); - $params['product_excludes'] = array( + $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified parent product(s).', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -324,7 +283,7 @@ class Controller extends ReportsController implements ExportableInterface { 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); - $params['variations'] = array( + $params['variations'] = array( 'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', @@ -333,14 +292,14 @@ class Controller extends ReportsController implements ExportableInterface { 'type' => 'integer', ), ); - $params['extended_info'] = array( + $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each variation to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); - $params['attribute_is'] = array( + $params['attribute_is'] = array( 'description' => __( 'Limit result set to variations that include the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -349,7 +308,7 @@ class Controller extends ReportsController implements ExportableInterface { 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); - $params['attribute_is_not'] = array( + $params['attribute_is_not'] = array( 'description' => __( 'Limit result set to variations that don\'t include the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( @@ -358,7 +317,7 @@ class Controller extends ReportsController implements ExportableInterface { 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); - $params['category_includes'] = array( + $params['category_includes'] = array( 'description' => __( 'Limit result set to variations in the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', @@ -367,7 +326,7 @@ class Controller extends ReportsController implements ExportableInterface { 'type' => 'integer', ), ); - $params['category_excludes'] = array( + $params['category_excludes'] = array( 'description' => __( 'Limit result set to variations not in the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', @@ -376,11 +335,14 @@ class Controller extends ReportsController implements ExportableInterface { 'type' => 'integer', ), ); - $params['force_cache_refresh'] = array( - 'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ), - 'type' => 'boolean', - 'sanitize_callback' => 'wp_validate_boolean', + $params['products'] = array( + 'description' => __( 'Limit result to items with specified product ids.', 'woocommerce' ), + 'type' => 'array', + 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', + 'items' => array( + 'type' => 'integer', + ), ); return $params; diff --git a/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php index ddcb6e048bf..5a12872ad66 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php @@ -9,7 +9,6 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; -use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** @@ -20,6 +19,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * + * @override ReportsDataStore::$table_name + * * @var string */ protected static $table_name = 'wc_order_product_lookup'; @@ -27,6 +28,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override ReportsDataStore::$cache_key + * * @var string */ protected $cache_key = 'variations'; @@ -34,6 +37,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * + * @override ReportsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -70,12 +75,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override ReportsDataStore::$context + * * @var string */ protected $context = 'variations'; /** * Assign report columns once full table name has been assigned. + * + * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); @@ -209,6 +218,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Maps ordering specified by the user to columns in the database/fields in the data. * + * @override ReportsDataStore::normalize_order_by() + * * @param string $order_by Sorting criterion. * * @return string @@ -372,146 +383,139 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @param array $query_args Query parameters. + * @override ReportsDataStore::get_default_query_vars() * - * @return stdClass|WP_Error Data. + * @return array Query parameters. */ - public function get_data( $query_args ) { + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['product_includes'] = array(); + $defaults['variation_includes'] = array(); + $defaults['extended_info'] = false; + + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override ReportsDataStore::get_noncached_data() + * + * @see get_data + * @param array $query_args Query parameters. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_data( $query_args ) { global $wpdb; $table_name = self::get_db_table_name(); - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'date', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'fields' => '*', - 'product_includes' => array(), - 'variation_includes' => array(), - 'extended_info' => false, + $this->initialize_queries(); + + $data = (object) array( + 'data' => array(), + 'total' => 0, + 'pages' => 0, + 'page_no' => 0, ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); + $selections = $this->selected_columns( $query_args ); + $included_variations = + ( isset( $query_args['variation_includes'] ) && is_array( $query_args['variation_includes'] ) ) + ? $query_args['variation_includes'] + : array(); + $params = $this->get_limit_params( $query_args ); + $this->add_sql_query_params( $query_args ); - if ( false === $data ) { - $this->initialize_queries(); + if ( count( $included_variations ) > 0 ) { + $total_results = count( $included_variations ); + $total_pages = (int) ceil( $total_results / $params['per_page'] ); - $data = (object) array( - 'data' => array(), - 'total' => 0, - 'pages' => 0, - 'page_no' => 0, - ); + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); - $selections = $this->selected_columns( $query_args ); - $included_variations = - ( isset( $query_args['variation_includes'] ) && is_array( $query_args['variation_includes'] ) ) - ? $query_args['variation_includes'] - : array(); - $params = $this->get_limit_params( $query_args ); - $this->add_sql_query_params( $query_args ); - - if ( count( $included_variations ) > 0 ) { - $total_results = count( $included_variations ); - $total_pages = (int) ceil( $total_results / $params['per_page'] ); - - $this->subquery->clear_sql_clause( 'select' ); - $this->subquery->add_sql_clause( 'select', $selections ); - - if ( 'date' === $query_args['orderby'] ) { - $this->subquery->add_sql_clause( 'select', ", {$table_name}.date_created" ); - } - - $fields = $this->get_fields( $query_args ); - $join_selections = $this->format_join_selections( $fields, array( 'variation_id' ) ); - $ids_table = $this->get_ids_table( $included_variations, 'variation_id' ); - - $this->add_sql_clause( 'select', $join_selections ); - $this->add_sql_clause( 'from', '(' ); - $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); - $this->add_sql_clause( 'from', ") AS {$table_name}" ); - $this->add_sql_clause( - 'right_join', - "RIGHT JOIN ( {$ids_table} ) AS default_results - ON default_results.variation_id = {$table_name}.variation_id" - ); - - $variations_query = $this->get_query_statement(); - } else { - - $this->subquery->clear_sql_clause( 'select' ); - $this->subquery->add_sql_clause( 'select', $selections ); - - /** - * Experimental: Filter the Variations SQL query allowing extensions to add additional SQL clauses. - * - * @since 7.4.0 - * @param array $query_args Query parameters. - * @param SqlQuery $subquery Variations query class. - */ - apply_filters( 'experimental_woocommerce_analytics_variations_additional_clauses', $query_args, $this->subquery ); - - /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ - $db_records_count = (int) $wpdb->get_var( - "SELECT COUNT(*) FROM ( - {$this->subquery->get_query_statement()} - ) AS tt" - ); - /* phpcs:enable */ - - $total_results = $db_records_count; - $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); - - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return $data; - } - - $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $variations_query = $this->subquery->get_query_statement(); + if ( 'date' === $query_args['orderby'] ) { + $this->subquery->add_sql_clause( 'select', ", {$table_name}.date_created" ); } - /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ - $product_data = $wpdb->get_results( - $variations_query, - ARRAY_A + $fields = $this->get_fields( $query_args ); + $join_selections = $this->format_join_selections( $fields, array( 'variation_id' ) ); + $ids_table = $this->get_ids_table( $included_variations, 'variation_id' ); + + $this->add_sql_clause( 'select', $join_selections ); + $this->add_sql_clause( 'from', '(' ); + $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); + $this->add_sql_clause( 'from', ") AS {$table_name}" ); + $this->add_sql_clause( + 'right_join', + "RIGHT JOIN ( {$ids_table} ) AS default_results + ON default_results.variation_id = {$table_name}.variation_id" + ); + + $variations_query = $this->get_query_statement(); + } else { + + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + + /** + * Experimental: Filter the Variations SQL query allowing extensions to add additional SQL clauses. + * + * @since 7.4.0 + * @param array $query_args Query parameters. + * @param SqlQuery $subquery Variations query class. + */ + apply_filters( 'experimental_woocommerce_analytics_variations_additional_clauses', $query_args, $this->subquery ); + + /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ + $db_records_count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM ( + {$this->subquery->get_query_statement()} + ) AS tt" ); /* phpcs:enable */ - if ( null === $product_data ) { + $total_results = $db_records_count; + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); + + if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } - $this->include_extended_info( $product_data, $query_args ); - - if ( $query_args['extended_info'] ) { - $this->fill_deleted_product_name( $product_data ); - } - - $product_data = array_map( array( $this, 'cast_numbers' ), $product_data ); - $data = (object) array( - 'data' => $product_data, - 'total' => $total_results, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - $this->set_cached_data( $cache_key, $data ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $variations_query = $this->subquery->get_query_statement(); } + /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ + $product_data = $wpdb->get_results( + $variations_query, + ARRAY_A + ); + /* phpcs:enable */ + + if ( null === $product_data ) { + return $data; + } + + $this->include_extended_info( $product_data, $query_args ); + + if ( $query_args['extended_info'] ) { + $this->fill_deleted_product_name( $product_data ); + } + + $product_data = array_map( array( $this, 'cast_numbers' ), $product_data ); + $data = (object) array( + 'data' => $product_data, + 'total' => $total_results, + 'pages' => $total_pages, + 'page_no' => (int) $query_args['page'], + ); + return $data; } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Variations/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Variations/Query.php index 906e9c70a0f..da0d19ec78a 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Variations/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Variations/Query.php @@ -22,24 +22,34 @@ use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Variations\Query + * + * @deprecated 9.3.0 Variations\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * + * @deprecated 9.3.0 Variations\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array(); } /** * Get product data based on the current query vars. * + * @deprecated 9.3.0 Variations\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_variations_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-variations' ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/Controller.php index 7aa2b7f7016..68e6590dce7 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/Controller.php @@ -9,8 +9,8 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats; defined( 'ABSPATH' ) || exit; +use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; -use Automattic\WooCommerce\Admin\API\Reports\ParameterException; use WP_REST_Request; use WP_REST_Response; @@ -46,12 +46,25 @@ class Controller extends GenericStatsController { } /** - * Get all reports. + * Get data from `'variations-stats'` GenericQuery. * - * @param WP_REST_Request $request Request data. - * @return array|WP_Error + * @override GenericController::get_datastore_data() + * + * @param array $query_args Query arguments. + * @return mixed Results from the data store. */ - public function get_items( $request ) { + protected function get_datastore_data( $query_args = array() ) { + $query = new GenericQuery( $query_args, 'variations-stats' ); + return $query->get_data(); + } + + /** + * Maps query arguments from the REST request, to be fed to Query. + * + * @param \WP_REST_Request $request Full request object. + * @return array Simplified array of params. + */ + protected function prepare_reports_query( $request ) { $query_args = array( 'fields' => array( 'items_sold', @@ -79,36 +92,13 @@ class Controller extends GenericStatsController { } } - $query = new Query( $query_args ); - try { - $report_data = $query->get_data(); - } catch ( ParameterException $e ) { - return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); - } - - $out_data = array( - 'totals' => get_object_vars( $report_data->totals ), - 'intervals' => array(), - ); - - foreach ( $report_data->intervals as $interval_data ) { - $item = $this->prepare_item_for_response( $interval_data, $request ); - $out_data['intervals'][] = $this->prepare_response_for_collection( $item ); - } - - return $this->add_pagination_headers( - $request, - $out_data, - (int) $report_data->total, - (int) $report_data->page_no, - (int) $report_data->pages - ); + return $query_args; } /** - * Prepare a report object for serialization. + * Prepare a report data item for serialization. * - * @param array $report Report data. + * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ @@ -288,15 +278,6 @@ class Controller extends GenericStatsController { ), 'validate_callback' => 'rest_validate_request_arg', ); - $params['fields'] = array( - 'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ), - 'type' => 'array', - 'sanitize_callback' => 'wp_parse_slug_list', - 'validate_callback' => 'rest_validate_request_arg', - 'items' => array( - 'type' => 'string', - ), - ); $params['attribute_is'] = array( 'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', diff --git a/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php index 0f928ddd98d..7ddfd78177b 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php @@ -10,16 +10,19 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore as VariationsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; -use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; +use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Variations\Stats\DataStore. */ class DataStore extends VariationsDataStore implements DataStoreInterface { + use StatsDataStoreTrait; /** * Mapping columns to data type to return correct response types. * + * @override VariationsDataStore::$column_types + * * @var array */ protected $column_types = array( @@ -32,6 +35,8 @@ class DataStore extends VariationsDataStore implements DataStoreInterface { /** * Cache identifier. * + * @override VariationsDataStore::$cache_key + * * @var string */ protected $cache_key = 'variations_stats'; @@ -39,12 +44,16 @@ class DataStore extends VariationsDataStore implements DataStoreInterface { /** * Data store context used to pass to filters. * + * @override VariationsDataStore::$context + * * @var string */ protected $context = 'variations_stats'; /** * Assign report columns once full table name has been assigned. + * + * @override VariationsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); @@ -133,144 +142,131 @@ class DataStore extends VariationsDataStore implements DataStoreInterface { } /** - * Returns the report data based on parameters supplied by the user. + * Get the default query arguments to be used by get_data(). + * These defaults are only partially applied when used via REST API, as that has its own defaults. * - * @since 3.5.0 - * @param array $query_args Query parameters. - * @return stdClass|WP_Error Data. + * @override VariationsDataStore::get_default_query_vars() + * + * @return array Query parameters. */ - public function get_data( $query_args ) { + public function get_default_query_vars() { + $defaults = parent::get_default_query_vars(); + $defaults['category_includes'] = array(); + $defaults['interval'] = 'week'; + unset( $defaults['extended_info'] ); + + return $defaults; + } + + /** + * Returns the report data based on normalized parameters. + * Will be called by `get_data` if there is no data in cache. + * + * @override VariationsDataStore::get_noncached_stats_data() + * + * @see get_data + * @see get_noncached_stats_data + * @param array $query_args Query parameters. + * @param array $params Query limit parameters. + * @param stdClass $data Reference to the data object to fill. + * @param int $expected_interval_count Number of expected intervals. + * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. + */ + public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); - // These defaults are only partially applied when used via REST API, as that has its own defaults. - $defaults = array( - 'per_page' => get_option( 'posts_per_page' ), - 'page' => 1, - 'order' => 'DESC', - 'orderby' => 'date', - 'before' => TimeInterval::default_before(), - 'after' => TimeInterval::default_after(), - 'fields' => '*', - 'category_includes' => array(), - 'interval' => 'week', - 'product_includes' => array(), - 'variation_includes' => array(), + $this->initialize_queries(); + + $selections = $this->selected_columns( $query_args ); + + $this->update_sql_query_params( $query_args ); + $this->get_limit_sql_params( $query_args ); + $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); + + /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ + $db_intervals = $wpdb->get_col( + $this->interval_query->get_query_statement() ); - $query_args = wp_parse_args( $query_args, $defaults ); - $this->normalize_timezones( $query_args, $defaults ); + /* phpcs:enable */ - /* - * We need to get the cache key here because - * parent::update_intervals_sql_params() modifies $query_args. - */ - $cache_key = $this->get_cache_key( $query_args ); - $data = $this->get_cached_data( $cache_key ); + $db_interval_count = count( $db_intervals ); - if ( false === $data ) { - $this->initialize_queries(); + $intervals = array(); + $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); + $this->total_query->add_sql_clause( 'select', $selections ); + $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); - $selections = $this->selected_columns( $query_args ); - $params = $this->get_limit_params( $query_args ); + /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ + $totals = $wpdb->get_results( + $this->total_query->get_query_statement(), + ARRAY_A + ); + /* phpcs:enable */ - $this->update_sql_query_params( $query_args ); - $this->get_limit_sql_params( $query_args ); - $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); + // phpcs:ignore Generic.Commenting.Todo.TaskFound + // @todo remove these assignements when refactoring segmenter classes to use query objects. + $totals_query = array( + 'from_clause' => $this->total_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->total_query->get_sql_clause( 'where' ), + ); + $intervals_query = array( + 'select_clause' => $this->get_sql_clause( 'select' ), + 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), + 'order_by' => $this->get_sql_clause( 'order_by' ), + 'limit' => $this->get_sql_clause( 'limit' ), + ); + $segmenter = new Segmenter( $query_args, $this->report_columns ); + $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); - /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ - $db_intervals = $wpdb->get_col( - $this->interval_query->get_query_statement() - ); - /* phpcs:enable */ - - $db_interval_count = count( $db_intervals ); - $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); - $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); - if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { - return array(); - } - - $intervals = array(); - $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); - $this->total_query->add_sql_clause( 'select', $selections ); - $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); - - /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ - $totals = $wpdb->get_results( - $this->total_query->get_query_statement(), - ARRAY_A - ); - /* phpcs:enable */ - - // @todo remove these assignements when refactoring segmenter classes to use query objects. - $totals_query = array( - 'from_clause' => $this->total_query->get_sql_clause( 'join' ), - 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), - 'where_clause' => $this->total_query->get_sql_clause( 'where' ), - ); - $intervals_query = array( - 'select_clause' => $this->get_sql_clause( 'select' ), - 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), - 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), - 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), - 'order_by' => $this->get_sql_clause( 'order_by' ), - 'limit' => $this->get_sql_clause( 'limit' ), - ); - $segmenter = new Segmenter( $query_args, $this->report_columns ); - $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); - - if ( null === $totals ) { - return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); - } - - $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); - $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); - if ( '' !== $selections ) { - $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); - } - - /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ - $intervals = $wpdb->get_results( - $this->interval_query->get_query_statement(), - ARRAY_A - ); - /* phpcs:enable */ - - if ( null === $intervals ) { - return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); - } - - $totals = (object) $this->cast_numbers( $totals[0] ); - - $data = (object) array( - 'totals' => $totals, - 'intervals' => $intervals, - 'total' => $expected_interval_count, - 'pages' => $total_pages, - 'page_no' => (int) $query_args['page'], - ); - - if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { - $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); - $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); - $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); - } else { - $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); - } - $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); - $this->create_interval_subtotals( $data->intervals ); - - $this->set_cached_data( $cache_key, $data ); + if ( null === $totals ) { + return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } + $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); + if ( '' !== $selections ) { + $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); + } + + /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ + $intervals = $wpdb->get_results( + $this->interval_query->get_query_statement(), + ARRAY_A + ); + /* phpcs:enable */ + + if ( null === $intervals ) { + return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); + } + + $totals = (object) $this->cast_numbers( $totals[0] ); + + $data->totals = $totals; + $data->intervals = $intervals; + + if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); + $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); + $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + } else { + $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); + } + $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); + return $data; } /** * Normalizes order_by clause to match to SQL query. * + * @override VariationsDataStore::normalize_order_by() + * * @param string $order_by Order by option requeste by user. * @return string */ @@ -281,18 +277,4 @@ class DataStore extends VariationsDataStore implements DataStoreInterface { return $order_by; } - - /** - * Initialize query objects. - */ - protected function initialize_queries() { - $this->clear_all_clauses(); - unset( $this->subquery ); - $this->total_query = new SqlQuery( $this->context . '_total' ); - $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); - - $this->interval_query = new SqlQuery( $this->context . '_interval' ); - $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); - $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); - } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/Query.php b/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/Query.php index 092b356fb9d..0613472bb38 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/Query.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/Query.php @@ -22,24 +22,34 @@ use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Variations\Stats\Query + * + * @deprecated 9.3.0 Variations\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * + * @deprecated 9.3.0 Variations\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ protected function get_default_query_vars() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + return array(); } /** * Get variations data based on the current query vars. * + * @deprecated 9.3.0 Variations\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. + * * @return array */ public function get_data() { + wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); + $args = apply_filters( 'woocommerce_analytics_variations_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-variations-stats' ); diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCCoreProfilerOptions.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCCoreProfilerOptions.php new file mode 100644 index 00000000000..ff0342c15ba --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCCoreProfilerOptions.php @@ -0,0 +1,59 @@ + $this->wp_get_option( 'blogname' ), + 'woocommerce_allow_tracking' => $this->wp_get_option( 'woocommerce_allow_tracking' ), + 'woocommerce_onboarding_profile' => $this->wp_get_option( 'woocommerce_onboarding_profile', array() ), + 'woocommerce_default_country' => $this->wp_get_option( 'woocommerce_default_country' ), + ) + ); + $step->set_meta_values( + array( + 'plugin' => 'woocommerce', + 'alias' => $this->get_alias(), + ) + ); + + return $step; + } + + /** + * Get the step name + * + * @return string + */ + public function get_step_name() { + return 'setSiteOptions'; + } + + /** + * Get the alias + * + * @return string + */ + public function get_alias() { + return 'setWCCoreProfilerOptions'; + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCPaymentGateways.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCPaymentGateways.php new file mode 100644 index 00000000000..666092c54c2 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCPaymentGateways.php @@ -0,0 +1,74 @@ +maybe_hide_wcpay_gateways(); + foreach ( $this->get_wc_payment_gateways() as $id => $payment_gateway ) { + if ( in_array( $id, $this->exclude_ids, true ) ) { + continue; + } + + $step->add_payment_gateway( + $id, + $payment_gateway->get_title(), + $payment_gateway->get_description(), + $payment_gateway->is_available() ? 'yes' : 'no' + ); + } + + return $step; + } + + /** + * Return the payment gateways resgietered in WooCommerce + * + * @return string + */ + public function get_wc_payment_gateways() { + return WC()->payment_gateways->payment_gateways(); + } + + /** + * Get the step name + * + * @return string + */ + public function get_step_name() { + return SetWCPaymentGateways::get_step_name(); + } + + /** + * Maybe hide WooCommerce Payments gateways + * + * @return void + */ + protected function maybe_hide_wcpay_gateways() { + if ( class_exists( 'WC_Payments' ) ) { + \WC_Payments::hide_gateways_on_settings_page(); + } + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCSettings.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCSettings.php new file mode 100644 index 00000000000..97f02b7452e --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCSettings.php @@ -0,0 +1,224 @@ +setting_pages = $setting_pages; + $this->wp_add_filter( 'wooblueprint_export_settings', array( $this, 'add_site_visibility_settings' ), 10, 3 ); + } + + /** + * Export WooCommerce settings. + * + * @return SetSiteOptions + */ + public function export() { + $pages = array(); + $options = array(); + $option_info = array(); + + foreach ( $this->setting_pages as $page ) { + $id = $page->get_id(); + if ( in_array( $id, $this->exclude_pages, true ) ) { + continue; + } + $pages[ $id ] = $this->get_page_info( $page ); + foreach ( $pages[ $id ]['options'] as $option ) { + $options[ $option['id'] ] = $option['value']; + $option_info[ $option['id'] ] = array( + 'location' => $option['location'], + 'title' => $option['title'], + ); + } + unset( $pages[ $id ]['options'] ); + } + + $filtered = $this->wp_apply_filters( 'wooblueprint_export_settings', $options, $pages, $option_info ); + + $step = new SetSiteOptions( $filtered['options'] ); + $step->set_meta_values( + array( + 'plugin' => 'woocommerce', + 'pages' => $filtered['pages'], + 'info' => $option_info, + 'alias' => $this->get_alias(), + ) + ); + + return $step; + } + + /** + * Get information about a settings page. + * + * @param WC_Settings_Page $page The settings page. + * @return array + */ + protected function get_page_info( WC_Settings_Page $page ) { + $info = array( + 'label' => $page->get_label(), + 'sections' => array(), + ); + + foreach ( $page->get_sections() as $id => $section ) { + $section_id = Util::camel_to_snake( strtolower( $section ) ); + $info['sections'][ $section_id ] = array( + 'label' => $section, + 'subsections' => array(), + ); + + $settings = $page->get_settings_for_section( $id ); + + // Get subsections. + $subsections = array_filter( + $settings, + function ( $setting ) { + return isset( $setting['type'] ) && 'title' === $setting['type'] && isset( $setting['title'] ); + } + ); + + foreach ( $subsections as $subsection ) { + if ( ! isset( $subsection['id'] ) ) { + $subsection['id'] = Util::camel_to_snake( strtolower( $subsection['title'] ) ); + } + + $info['sections'][ $section_id ]['subsections'][ $subsection['id'] ] = array( + 'label' => $subsection['title'], + ); + } + + // Get options. + $info['options'] = $this->get_page_section_settings( $settings, $page->get_id(), $section_id ); + } + return $info; + } + + /** + * Get settings for a specific page section. + * + * @param array $settings The settings. + * @param string $page The page ID. + * @param string $section The section ID. + * @return array + */ + private function get_page_section_settings( $settings, $page, $section = '' ) { + $current_title = ''; + $data = array(); + foreach ( $settings as $setting ) { + if ( 'sectionend' === $setting['type'] || 'slotfill_placeholder' === $setting['type'] || ! isset( $setting['id'] ) ) { + continue; + } + + if ( 'title' === $setting['type'] ) { + $current_title = Util::camel_to_snake( strtolower( $setting['title'] ) ); + } else { + $location = $page . '.' . $section; + if ( $current_title ) { + $location .= '.' . $current_title; + } + + $data[] = array( + 'id' => $setting['id'], + 'value' => $this->wp_get_option( $setting['id'], $setting['default'] ?? null ), + 'title' => $setting['title'] ?? $setting['desc'] ?? '', + 'location' => $location, + ); + } + } + return $data; + } + + /** + * Add site visibility settings. + * + * @param array $options The options array. + * @param array $pages The pages array. + * @param array $option_info The option information array. + * @return array + */ + public function add_site_visibility_settings( array $options, array $pages, array $option_info ) { + $pages['site_visibility'] = array( + 'label' => 'Site Visibility', + 'sections' => array( + 'general' => array( + 'label' => 'General', + ), + ), + ); + + $options['woocommerce_coming_soon'] = $this->wp_get_option( 'woocommerce_coming_soon' ); + $options['woocommerce_store_pages_only'] = $this->wp_get_option( 'woocommerce_store_pages_only' ); + + $option_info['woocommerce_coming_soon'] = array( + 'location' => 'site_visibility.general', + 'title' => 'Coming soon', + ); + + $option_info['woocommerce_store_pages_only'] = array( + 'location' => 'site_visibility.general', + 'title' => 'Apply to store pages only', + ); + + return compact( 'options', 'pages', 'option_info' ); + } + + /** + * Get the name of the step. + * + * @return string + */ + public function get_step_name() { + return 'setSiteOptions'; + } + + /** + * Get the alias for this exporter. + * + * @return string + */ + public function get_alias() { + return 'setWCSettings'; + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCShipping.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCShipping.php new file mode 100644 index 00000000000..93fed342c79 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCShipping.php @@ -0,0 +1,163 @@ +get_results( + " + SELECT * + FROM {$wpdb->prefix}term_taxonomy + WHERE taxonomy = 'product_shipping_class' + " + ); + + $term_ids = array(); + + // Collect term IDs. + foreach ( $classes as $term ) { + $term_ids[] = (int) $term->term_id; + } + + $term_ids = implode( ', ', $term_ids ); + + // Fetch terms based on term IDs. + if ( ! empty( $term_ids ) ) { + $terms = $wpdb->get_results( + $wpdb->prepare( + " + SELECT * + FROM {$wpdb->prefix}terms + WHERE term_id IN (%s) + ", + $term_ids + ) + ); + } else { + $terms = array(); + } + + // Fetch local pickup settings. + $local_pickup = array( + 'general' => get_option( 'woocommerce_pickup_location_settings', array() ), + 'locations' => get_option( 'pickup_location_pickup_locations', array() ), + ); + + if ( empty( $local_pickup['general'] ) ) { + $local_pickup['general'] = new \stdClass(); + } + + // Fetch shipping zones from the database. + $zones = $wpdb->get_results( + " + SELECT * + FROM {$wpdb->prefix}woocommerce_shipping_zones + " + ); + + // Fetch shipping zone methods from the database. + $methods = $wpdb->get_results( + " + SELECT * + FROM {$wpdb->prefix}woocommerce_shipping_zone_methods + " + ); + + // Fetch shipping method options. + // Each method has a corresponding option in the options table. + $method_options = $wpdb->get_results( + " + SELECT * + FROM {$wpdb->prefix}options + WHERE option_name LIKE 'woocommerce_flat_rate_%_settings' + or option_name LIKE 'woocommerce_free_shipping_%_settings' + ", + ARRAY_A + ); + + $method_options = Util::index_array( + $method_options, + function ( $key, $option ) { + return $option['option_name']; + } + ); + + foreach ( $methods as $method ) { + $key_name = 'woocommerce_' . $method->method_id . '_' . $method->instance_id . '_settings'; + if ( isset( $method_options[ $key_name ] ) ) { + $method->settings = array( + 'option_name' => $key_name, + 'option_value' => maybe_unserialize( $method_options[ $key_name ]['option_value'] ), + ); + } + } + + $methods_by_zone_id = array(); + + // Organize methods by zone ID. + foreach ( $methods as $method ) { + if ( ! isset( $methods_by_zone_id[ $method->zone_id ] ) ) { + $methods_by_zone_id[ $method->zone_id ] = array(); + } + $methods_by_zone_id[ $method->zone_id ][] = $method->method_id; + } + + // Fetch shipping zone locations from the database. + $locations = $wpdb->get_results( + " + SELECT * + FROM {$wpdb->prefix}woocommerce_shipping_zone_locations + " + ); + + $locations_by_zone_id = array(); + + // Organize locations by zone ID. + foreach ( $locations as $location ) { + if ( ! isset( $locations_by_zone_id[ $location->zone_id ] ) ) { + $locations_by_zone_id[ $location->zone_id ] = array(); + } + $locations_by_zone_id[ $location->zone_id ][] = $location->location_id; + } + + // Create a new SetWCShipping step with the fetched data. + $step = new SetWCShipping( $methods, $locations, $zones, $terms, $classes, $local_pickup ); + $step->set_meta_values( + array( + 'plugin' => 'woocommerce', + ) + ); + + return $step; + } + + /** + * Get the name of the step. + * + * @return string + */ + public function get_step_name() { + return SetWCShipping::get_step_name(); + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCTaskOptions.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCTaskOptions.php new file mode 100644 index 00000000000..e71215a0d88 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCTaskOptions.php @@ -0,0 +1,63 @@ + $this->wp_get_option( 'woocommerce_admin_customize_store_completed', 'no' ), + 'woocommerce_task_list_tracked_completed_actions' => $this->wp_get_option( 'woocommerce_task_list_tracked_completed_actions', array() ), + ) + ); + + $step->set_meta_values( + array( + 'plugin' => 'woocommerce', + 'alias' => $this->get_alias(), + ) + ); + + return $step; + } + + /** + * Get the name of the step. + * + * @return string + */ + public function get_step_name() { + return 'setOptions'; + } + + /** + * Get the alias for this exporter. + * + * @return string + */ + public function get_alias() { + return 'setWCTaskOptions'; + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCTaxRates.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCTaxRates.php new file mode 100644 index 00000000000..d6bfe6d99f1 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCTaxRates.php @@ -0,0 +1,64 @@ +get_results( + " + SELECT * + FROM {$wpdb->prefix}woocommerce_tax_rates as tax_rates + ", + ARRAY_A + ); + + // Fetch tax rate locations from the database. + $locations = $wpdb->get_results( + " + SELECT * + FROM {$wpdb->prefix}woocommerce_tax_rate_locations as locations + ", + ARRAY_A + ); + + // Create a new SetWCTaxRates step with the fetched data. + $step = new SetWCTaxRates( $rates, $locations ); + $step->set_meta_values( + array( + 'plugin' => 'woocommerce', + ) + ); + + return $step; + } + + /** + * Get the name of the step. + * + * @return string + */ + public function get_step_name() { + return 'setWCTaxRates'; + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Importers/ImportSetWCPaymentGateways.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Importers/ImportSetWCPaymentGateways.php new file mode 100644 index 00000000000..ec8fd3996c6 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Importers/ImportSetWCPaymentGateways.php @@ -0,0 +1,71 @@ +get_wc_payment_gateways(); + $fields = array( 'title', 'description', 'enabled' ); + + foreach ( $schema->payment_gateways as $id => $payment_gateway_data ) { + if ( ! isset( $payment_gateways[ $id ] ) ) { + $result->add_info( "Skipping {$id}. The payment gateway is not available" ); + continue; + } + + $payment_gateway = $payment_gateways[ $id ]; + + // Refer to https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/class-wc-ajax.php#L3564. + foreach ( $fields as $field ) { + if ( isset( $payment_gateway_data->{$field} ) ) { + $payment_gateway->update_option( $field, $payment_gateway_data->{$field} ); + } + } + $result->add_info( "{$id} has been updated." ); + $this->wp_do_action( 'woocommerce_update_options' ); + } + + return $result; + } + + /** + * Return the payment gateways resgietered in WooCommerce + * + * @return string + */ + public function get_wc_payment_gateways() { + return WC()->payment_gateways->payment_gateways(); + } + + /** + * Get the class name for the step. + * + * @return string + */ + public function get_step_class(): string { + return SetWCPaymentGateways::class; + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Importers/ImportSetWCShipping.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Importers/ImportSetWCShipping.php new file mode 100644 index 00000000000..d2ca833c4ac --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Importers/ImportSetWCShipping.php @@ -0,0 +1,146 @@ + array( 'terms', array( '%d', '%s', '%s', '%d' ) ), + 'classes' => array( 'term_taxonomy', array( '%d', '%d', '%s', '%s', '%d', '%d' ) ), + 'shipping_zones' => array( 'woocommerce_shipping_zones', array( '%d', '%s', '%d' ) ), + 'shipping_methods' => array( 'woocommerce_shipping_zone_methods', array( '%d', '%d', '%s', '%d', '%d' ) ), + 'shipping_locations' => array( 'woocommerce_shipping_zone_locations', array( '%d', '%d', '%s', '%s' ) ), + ); + + foreach ( $fields as $name => $data ) { + if ( isset( $schema->values->{$name} ) ) { + $filter_method = 'filter_' . $name . '_data'; + if ( method_exists( $this, $filter_method ) ) { + $insert_values = $this->$filter_method( $schema->values->{$name} ); + } else { + $insert_values = $schema->values->{$name}; + } + + $this->insert( $data[0], $data[1], $insert_values ); + // check if function with process_$name exist and call it. + $method = 'post_process_' . $name; + if ( method_exists( $this, $method ) ) { + $this->$method( $schema->values->{$name} ); + } + } + } + + if ( isset( $schema->values->local_pickup ) ) { + $this->add_local_pickup( $schema->values->local_pickup ); + } + + return $result; + } + + /** + * Filter shipping methods data. + * + * @param array $methods The shipping methods. + * + * @return mixed + */ + protected function filter_shipping_methods_data( $methods ) { + return array_map( + function ( $method ) { + unset( $method->settings ); + return $method; + }, + $methods + ); + } + + /** + * Post process shipping methods. + * + * @param array $methods The shipping methods. + * + * @return void + */ + protected function post_process_shipping_methods( $methods ) { + foreach ( $methods as $method ) { + if ( isset( $method->settings ) ) { + update_option( $method->option_name, $method->option_value ); + } + } + } + + /** + * Insert data into the specified table. + * + * @param string $table The table name. + * @param array $format The data format. + * @param array $rows The rows to insert. + * @global \wpdb $wpdb WordPress database abstraction object. + * @return array The IDs of the inserted rows. + */ + protected function insert( $table, $format, $rows ) { + global $wpdb; + $inserted_ids = array(); + $table = $wpdb->prefix . $table; + $format = implode( ', ', $format ); + foreach ( $rows as $row ) { + $row = (array) $row; + $columns = implode( ', ', array_keys( $row ) ); + // phpcs:ignore + $sql = $wpdb->prepare( "REPLACE INTO $table ($columns) VALUES ($format)", $row ); + // phpcs:ignore + $wpdb->query( $sql ); + } + return $inserted_ids; + } + + /** + * Add local pickup settings. + * + * @param object $local_pickup The local pickup settings. + */ + private function add_local_pickup( $local_pickup ) { + if ( isset( $local_pickup->general ) ) { + $this->wp_update_option( 'woocommerce_pickup_location_settings', (array) $local_pickup->general ); + } + + if ( isset( $local_pickup->locations ) ) { + $local_pickup->locations = json_decode( wp_json_encode( $local_pickup->locations ), true ); + $this->wp_update_option( 'pickup_location_pickup_locations', $local_pickup->locations ); + } + } + + /** + * Get the class name for the step. + * + * @return string + */ + public function get_step_class(): string { + return SetWCShipping::class; + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Importers/ImportSetWCTaxRates.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Importers/ImportSetWCTaxRates.php new file mode 100644 index 00000000000..34ab8cfc81b --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Importers/ImportSetWCTaxRates.php @@ -0,0 +1,124 @@ +result = StepProcessorResult::success( SetWCTaxRates::get_step_name() ); + + foreach ( $schema->values->rates as $rate ) { + $this->add_rate( $rate ); + } + + foreach ( $schema->values->locations as $location ) { + $this->add_location( $location ); + } + + return $this->result; + } + + /** + * Check if a tax rate exists in the database. + * + * @param int $id The tax rate ID. + * @global \wpdb $wpdb WordPress database abstraction object. + * @return array|null The tax rate row if found, null otherwise. + */ + protected function exist( $id ) { + global $wpdb; + return $wpdb->get_row( + $wpdb->prepare( + " + SELECT * + FROM {$wpdb->prefix}woocommerce_tax_rates + WHERE tax_rate_id = %d + ", + $id + ), + ARRAY_A + ); + } + + /** + * Add a tax rate to the database. + * + * @param object $rate The tax rate object. + * @return int|false The tax rate ID if successfully added, false otherwise. + */ + protected function add_rate( $rate ) { + $tax_rate = (array) $rate; + + if ( $this->exist( $tax_rate['tax_rate_id'] ) ) { + $this->result->add_info( "Tax rate with I.D {$tax_rate['tax_rate_id']} already exists. Skipped creating it." ); + return false; + } + + $tax_rate_id = WC_Tax::_insert_tax_rate( $tax_rate ); + + if ( isset( $rate->postcode ) ) { + $postcode = array_map( 'wc_clean', explode( ';', $rate->postcode ) ); + $postcode = array_map( 'wc_normalize_postcode', $postcode ); + WC_Tax::_update_tax_rate_postcodes( $tax_rate_id, $postcode ); + } + if ( isset( $rate->city ) ) { + $cities = explode( ';', $rate->city ); + WC_Tax::_update_tax_rate_cities( $tax_rate_id, array_map( 'wc_clean', array_map( 'wp_unslash', $cities ) ) ); + } + + return $tax_rate_id; + } + + /** + * Add a tax rate location to the database. + * + * @param object $location The location object. + * @global \wpdb $wpdb WordPress database abstraction object. + */ + public function add_location( $location ) { + global $wpdb; + $location = (array) $location; + $columns = implode( ',', array_keys( $location ) ); + $format = implode( ',', array( '%d', '%s', '%d', '%s' ) ); + $table = $wpdb->prefix . 'woocommerce_tax_rate_locations'; + // phpcs:ignore + $sql = $wpdb->prepare( "REPLACE INTO $table ($columns) VALUES ($format)", $location ); + // phpcs:ignore + $wpdb->query( $sql ); + } + + /** + * Get the class name for the step. + * + * @return string + */ + public function get_step_class(): string { + return SetWCTaxRates::class; + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Init.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Init.php new file mode 100644 index 00000000000..b24fa690732 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Init.php @@ -0,0 +1,115 @@ +register_routes(); + } + + + /** + * Add upload nonce to global JS settings. + * + * The value can be accessed at wcSettings.admin.blueprint_upload_nonce + * + * @param array $settings Global JS settings. + * + * @return array + */ + public function add_upload_nonce_to_settings( array $settings ) { + if ( ! is_admin() ) { + return $settings; + } + + $page_id = PageController::get_instance()->get_current_screen_id(); + if ( 'woocommerce_page_wc-admin' === $page_id ) { + $settings['blueprint_upload_nonce'] = wp_create_nonce( 'blueprint_upload_nonce' ); + return $settings; + } + + return $settings; + } + + /** + * Add Woo Specific Exporters. + * + * @param StepExporter[] $exporters Array of step exporters. + * + * @return StepExporter[] + */ + public function add_woo_exporters( array $exporters ) { + return array_merge( + $exporters, + array( + new ExportWCCoreProfilerOptions(), + new ExportWCSettings(), + new ExportWCPaymentGateways(), + new ExportWCShipping(), + new ExportWCTaskOptions(), + new ExportWCTaxRates(), + ) + ); + } + + /** + * Add Woo Specific Importers. + * + * @param StepProcessor[] $importers Array of step processors. + * + * @return array + */ + public function add_woo_importers( array $importers ) { + return array_merge( + $importers, + array( + new ImportSetWCPaymentGateways(), + new ImportSetWCShipping(), + new ImportSetWCTaxRates(), + ) + ); + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/RestApi.php b/plugins/woocommerce/src/Admin/Features/Blueprint/RestApi.php new file mode 100644 index 00000000000..5fe1b1f6601 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/RestApi.php @@ -0,0 +1,213 @@ +namespace, + '/import', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'import' ), + 'permission_callback' => array( $this, 'check_permission' ), + ), + ) + ); + + register_rest_route( + $this->namespace, + '/export', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'export' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => array( + 'steps' => array( + 'description' => __( 'A list of plugins to install', 'woocommerce' ), + 'type' => 'array', + 'items' => 'string', + 'default' => array(), + 'sanitize_callback' => function ( $value ) { + return array_map( + function ( $value ) { + return sanitize_text_field( $value ); + }, + $value + ); + }, + 'required' => false, + ), + 'export_as_zip' => array( + 'description' => __( 'Export as a zip file', 'woocommerce' ), + 'type' => 'boolean', + 'default' => false, + 'required' => false, + ), + ), + ), + ) + ); + } + + /** + * Check if the current user has permission to perform the request. + * + * @return bool|\WP_Error + */ + public function check_permission() { + if ( ! current_user_can( 'install_plugins' ) ) { + return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); + } + return true; + } + + /** + * Handle the export request. + * + * @param \WP_REST_Request $request The request object. + * @return \WP_HTTP_Response The response object. + */ + public function export( $request ) { + $steps = $request->get_param( 'steps' ); + $export_as_zip = $request->get_param( 'export_as_zip' ); + $exporter = new ExportSchema(); + + $data = $exporter->export( $steps, $export_as_zip ); + + if ( $export_as_zip ) { + $zip = new ZipExportedSchema( $data ); + $data = $zip->zip(); + $data = site_url( str_replace( ABSPATH, '', $data ) ); + } + + return new \WP_HTTP_Response( + array( + 'data' => $data, + 'type' => $export_as_zip ? 'zip' : 'json', + ) + ); + } + + /** + * Handle the import request. + * + * @return \WP_HTTP_Response The response object. + * @throws \InvalidArgumentException If the import fails. + */ + public function import() { + + // Check for nonce to prevent CSRF. + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + if ( ! isset( $_POST['blueprint_upload_nonce'] ) || ! \wp_verify_nonce( $_POST['blueprint_upload_nonce'], 'blueprint_upload_nonce' ) ) { + return new \WP_HTTP_Response( + array( + 'status' => 'error', + 'message' => __( 'Invalid nonce', 'woocommerce' ), + ), + 400 + ); + } + + // phpcs:ignore + if ( ! empty( $_FILES['file'] ) && $_FILES['file']['error'] === UPLOAD_ERR_OK ) { + // phpcs:ignore + $uploaded_file = $_FILES['file']['tmp_name']; + // phpcs:ignore + $mime_type = $_FILES['file']['type']; + + if ( 'application/json' !== $mime_type && 'application/zip' !== $mime_type ) { + return new \WP_HTTP_Response( + array( + 'status' => 'error', + 'message' => __( 'Invalid file type', 'woocommerce' ), + ), + 400 + ); + } + + try { + // phpcs:ignore + if ( $mime_type === 'application/zip' ) { + // phpcs:ignore + if ( ! function_exists( 'wp_handle_upload' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $movefile = \wp_handle_upload( $_FILES['file'], array( 'test_form' => false ) ); + + if ( $movefile && ! isset( $movefile['error'] ) ) { + $blueprint = ImportSchema::create_from_zip( $movefile['file'] ); + } else { + throw new InvalidArgumentException( $movefile['error'] ); + } + } else { + $blueprint = ImportSchema::create_from_json( $uploaded_file ); + } + } catch ( \Exception $e ) { + return new \WP_HTTP_Response( + array( + 'status' => 'error', + 'message' => $e->getMessage(), + ), + 400 + ); + } + + $results = $blueprint->import(); + $result_formatter = new JsonResultFormatter( $results ); + $redirect = $blueprint->get_schema()->landingPage ?? null; + $redirect_url = $redirect->url ?? 'admin.php?page=wc-admin'; + + $is_success = $result_formatter->is_success() ? 'success' : 'error'; + + return new \WP_HTTP_Response( + array( + 'status' => $is_success, + 'message' => 'error' === $is_success ? __( 'There was an error while processing your schema', 'woocommerce' ) : 'success', + 'data' => array( + 'redirect' => admin_url( $redirect_url ), + 'result' => $result_formatter->format(), + ), + ), + 200 + ); + } + + return new \WP_HTTP_Response( + array( + 'status' => 'error', + 'message' => __( 'No file uploaded', 'woocommerce' ), + ), + 400 + ); + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Steps/SetWCPaymentGateways.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Steps/SetWCPaymentGateways.php new file mode 100644 index 00000000000..f1b9830b27b --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Steps/SetWCPaymentGateways.php @@ -0,0 +1,111 @@ +payment_gateways = $payment_gateways; + } + + /** + * Add a payment gateway. + * + * @param string $id The ID of the payment gateway. + * @param string $title The title of the payment gateway. + * @param string $description The description of the payment gateway. + * @param string $enabled Whether the payment gateway is enabled ('yes' or 'no'). + */ + public function add_payment_gateway( $id, $title, $description, $enabled ) { + $this->payment_gateways[ $id ] = array( + 'title' => $title, + 'description' => $description, + 'enabled' => $enabled, + ); + } + + /** + * Get the name of the step. + * + * @return string + */ + public static function get_step_name(): string { + return 'setWCPaymentGateways'; + } + + /** + * Get the schema for the step. + * + * @param int $version Optional version number of the schema. + * @return array The schema array. + */ + public static function get_schema( $version = 1 ): array { + return array( + 'type' => 'object', + 'properties' => array( + 'step' => array( + 'type' => 'string', + 'enum' => array( 'setWCPaymentGateways' ), + ), + 'payment_gateways' => array( + 'type' => 'object', + 'patternProperties' => array( + '^[a-zA-Z0-9_]+$' => array( + 'type' => 'object', + 'properties' => array( + 'title' => array( + 'type' => 'string', + ), + 'description' => array( + 'type' => 'string', + ), + 'enabled' => array( + 'type' => 'string', + 'enum' => array( 'yes', 'no' ), + ), + ), + 'required' => array( 'title', 'description', 'enabled' ), + ), + ), + 'additionalProperties' => false, + ), + ), + 'required' => array( 'step', 'payment_gateways' ), + ); + } + + /** + * Prepare the JSON array for the step. + * + * @return array The JSON array. + */ + public function prepare_json_array(): array { + return array( + 'step' => static::get_step_name(), + 'payment_gateways' => $this->payment_gateways, + ); + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Steps/SetWCShipping.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Steps/SetWCShipping.php new file mode 100644 index 00000000000..57bd05a4575 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Steps/SetWCShipping.php @@ -0,0 +1,232 @@ +methods = $methods; + $this->locations = $locations; + $this->zones = $zones; + $this->terms = $terms; + $this->classes = $classes; + $this->local_pickup = $local_pickup; + } + + /** + * Prepare the JSON array for the step. + * + * @return array The JSON array. + */ + public function prepare_json_array(): array { + return array( + 'step' => static::get_step_name(), + 'values' => array( + 'shipping_methods' => $this->methods, + 'shipping_locations' => $this->locations, + 'shipping_zones' => $this->zones, + 'terms' => $this->terms, + 'classes' => $this->classes, + 'local_pickup' => $this->local_pickup, + ), + ); + } + + /** + * Get the name of the step. + * + * @return string + */ + public static function get_step_name(): string { + return 'setWCShipping'; + } + + /** + * Get the schema for the step. + * + * @param int $version Optional version number of the schema. + * @return array The schema array. + */ + public static function get_schema( $version = 1 ): array { + return array( + 'type' => 'object', + 'properties' => array( + 'step' => array( + 'type' => 'string', + 'enum' => array( static::get_step_name() ), + ), + 'values' => array( + 'type' => 'object', + 'properties' => array( + 'classes' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'term_taxonomy_id' => array( 'type' => 'string' ), + 'term_id' => array( 'type' => 'string' ), + 'taxonomy' => array( 'type' => 'string' ), + 'description' => array( 'type' => 'string' ), + 'parent' => array( 'type' => 'string' ), + 'count' => array( 'type' => 'string' ), + ), + 'required' => array( 'term_taxonomy_id', 'term_id', 'taxonomy', 'description', 'parent', 'count' ), + ), + ), + 'terms' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'term_id' => array( 'type' => 'string' ), + 'name' => array( 'type' => 'string' ), + 'slug' => array( 'type' => 'string' ), + 'term_group' => array( 'type' => 'string' ), + ), + 'required' => array( 'term_id', 'name', 'slug', 'term_group' ), + ), + ), + 'local_pickup' => array( + 'type' => 'object', + 'properties' => array( + 'general' => array( + 'type' => 'object', + 'properties' => array( + 'enabled' => array( 'type' => 'string' ), + 'title' => array( 'type' => 'string' ), + 'tax_status' => array( 'type' => 'string' ), + 'cost' => array( 'type' => 'string' ), + ), + ), + 'locations' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( 'type' => 'string' ), + 'address' => array( + 'type' => 'object', + 'properties' => array( + 'address_1' => array( 'type' => 'string' ), + 'city' => array( 'type' => 'string' ), + 'state' => array( 'type' => 'string' ), + 'postcode' => array( 'type' => 'string' ), + 'country' => array( 'type' => 'string' ), + ), + ), + 'details' => array( 'type' => 'string' ), + 'enabled' => array( 'type' => 'boolean' ), + ), + ), + ), + ), + ), + 'shipping_methods' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'zone_id' => array( 'type' => 'string' ), + 'instance_id' => array( 'type' => 'string' ), + 'method_id' => array( 'type' => 'string' ), + 'method_order' => array( 'type' => 'string' ), + 'is_enabled' => array( 'type' => 'string' ), + ), + 'required' => array( 'zone_id', 'instance_id', 'method_id', 'method_order', 'is_enabled' ), + ), + ), + 'shipping_locations' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'location_id' => array( 'type' => 'string' ), + 'zone_id' => array( 'type' => 'string' ), + 'location_code' => array( 'type' => 'string' ), + 'location_type' => array( 'type' => 'string' ), + ), + 'required' => array( 'location_id', 'zone_id', 'location_code', 'location_type' ), + ), + ), + 'shipping_zones' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'zone_id' => array( 'type' => 'string' ), + 'zone_name' => array( 'type' => 'string' ), + 'zone_order' => array( 'type' => 'string' ), + ), + 'required' => array( 'zone_id', 'zone_name', 'zone_order' ), + ), + ), + ), + ), + ), + 'required' => array( 'step', 'values' ), + ); + } +} diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Steps/SetWCTaxRates.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Steps/SetWCTaxRates.php new file mode 100644 index 00000000000..d99647e82e3 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Steps/SetWCTaxRates.php @@ -0,0 +1,134 @@ +rates = $rates; + $this->locations = $locations; + } + + /** + * Prepare the JSON array for the step. + * + * @return array The JSON array. + */ + public function prepare_json_array(): array { + return array( + 'step' => static::get_step_name(), + 'values' => array( + 'rates' => $this->rates, + 'locations' => $this->locations, + ), + ); + } + + /** + * Get the name of the step. + * + * @return string + */ + public static function get_step_name(): string { + return 'setWCTaxRates'; + } + + /** + * Get the schema for the step. + * + * @param int $version Optional version number of the schema. + * @return array The schema array. + */ + public static function get_schema( $version = 1 ): array { + return array( + 'type' => 'object', + 'properties' => array( + 'step' => array( + 'type' => 'string', + 'enum' => array( static::get_step_name() ), + ), + 'values' => array( + 'type' => 'object', + 'properties' => array( + 'rates' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'tax_rate_id' => array( 'type' => 'string' ), + 'tax_rate_country' => array( 'type' => 'string' ), + 'tax_rate_state' => array( 'type' => 'string' ), + 'tax_rate' => array( 'type' => 'string' ), + 'tax_rate_name' => array( 'type' => 'string' ), + 'tax_rate_priority' => array( 'type' => 'string' ), + 'tax_rate_compound' => array( 'type' => 'string' ), + 'tax_rate_shipping' => array( 'type' => 'string' ), + 'tax_rate_order' => array( 'type' => 'string' ), + 'tax_rate_class' => array( 'type' => 'string' ), + ), + 'required' => array( + 'tax_rate_id', + 'tax_rate_country', + 'tax_rate_state', + 'tax_rate', + 'tax_rate_name', + 'tax_rate_priority', + 'tax_rate_compound', + 'tax_rate_shipping', + 'tax_rate_order', + 'tax_rate_class', + ), + ), + ), + 'locations' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'location_id' => array( 'type' => 'string' ), + 'location_code' => array( 'type' => 'string' ), + 'tax_rate_id' => array( 'type' => 'string' ), + 'location_type' => array( 'type' => 'string' ), + ), + 'required' => array( 'location_id', 'location_code', 'tax_rate_id', 'location_type' ), + ), + ), + ), + 'required' => array( 'rates' ), + ), + ), + 'required' => array( 'step', 'values' ), + ); + } +} diff --git a/plugins/woocommerce/src/Admin/Features/LaunchYourStore.php b/plugins/woocommerce/src/Admin/Features/LaunchYourStore.php index cc83a73dd15..3bfff4518fe 100644 --- a/plugins/woocommerce/src/Admin/Features/LaunchYourStore.php +++ b/plugins/woocommerce/src/Admin/Features/LaunchYourStore.php @@ -5,12 +5,13 @@ namespace Automattic\WooCommerce\Admin\Features; use Automattic\WooCommerce\Admin\PageController; use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils; use Automattic\WooCommerce\Admin\WCAdminHelper; +use Automattic\WooCommerce\Internal\Admin\WCAdminUser; /** * Takes care of Launch Your Store related actions. */ class LaunchYourStore { - const BANNER_DISMISS_USER_META_KEY = 'woocommerce_coming_soon_banner_dismissed'; + const BANNER_DISMISS_USER_META_KEY = 'coming_soon_banner_dismissed'; /** * Constructor. */ @@ -21,6 +22,7 @@ class LaunchYourStore { add_action( 'init', array( $this, 'register_launch_your_store_user_meta_fields' ) ); add_filter( 'woocommerce_tracks_event_properties', array( $this, 'append_coming_soon_global_tracks' ), 10, 2 ); add_action( 'wp_login', array( $this, 'reset_woocommerce_coming_soon_banner_dismissed' ), 10, 2 ); + add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) ); } /** @@ -160,7 +162,10 @@ class LaunchYourStore { return false; } - if ( get_user_meta( $current_user_id, self::BANNER_DISMISS_USER_META_KEY, true ) === 'yes' ) { + $has_dismissed_banner = WCAdminUser::get_user_data_field( $current_user_id, self::BANNER_DISMISS_USER_META_KEY ) + // Remove this check in WC 9.4. + || get_user_meta( $current_user_id, 'woocommerce_' . self::BANNER_DISMISS_USER_META_KEY, true ) === 'yes'; + if ( $has_dismissed_banner ) { return false; } @@ -198,6 +203,8 @@ class LaunchYourStore { /** * Register user meta fields for Launch Your Store. + * + * This should be removed in WC 9.4. */ public function register_launch_your_store_user_meta_fields() { if ( ! $this->is_manager_or_admin() ) { @@ -217,7 +224,7 @@ class LaunchYourStore { register_meta( 'user', - self::BANNER_DISMISS_USER_META_KEY, + 'woocommerce_coming_soon_banner_dismissed', array( 'type' => 'string', 'description' => 'Indicate whether the user has dismissed the coming soon notice or not.', @@ -227,6 +234,22 @@ class LaunchYourStore { ); } + /** + * Register user meta fields for Launch Your Store. + * + * @param array $user_data_fields user data fields. + * @return array + */ + public function add_user_data_fields( $user_data_fields ) { + return array_merge( + $user_data_fields, + array( + 'launch_your_store_tour_hidden', + self::BANNER_DISMISS_USER_META_KEY, + ) + ); + } + /** * Reset 'woocommerce_coming_soon_banner_dismissed' user meta to 'no'. * @@ -236,9 +259,9 @@ class LaunchYourStore { * @param object $user user object. */ public function reset_woocommerce_coming_soon_banner_dismissed( $user_login, $user ) { - $existing_meta = get_user_meta( $user->ID, self::BANNER_DISMISS_USER_META_KEY, true ); + $existing_meta = WCAdminUser::get_user_data_field( $user->ID, self::BANNER_DISMISS_USER_META_KEY ); if ( 'yes' === $existing_meta ) { - update_user_meta( $user->ID, self::BANNER_DISMISS_USER_META_KEY, 'no' ); + WCAdminUser::update_user_data_field( $user->ID, self::BANNER_DISMISS_USER_META_KEY, 'no' ); } } } diff --git a/plugins/woocommerce/src/Admin/Features/MarketingRecommendations/DefaultMarketingRecommendations.php b/plugins/woocommerce/src/Admin/Features/MarketingRecommendations/DefaultMarketingRecommendations.php index 25f1626d6dc..c46decb0f7f 100644 --- a/plugins/woocommerce/src/Admin/Features/MarketingRecommendations/DefaultMarketingRecommendations.php +++ b/plugins/woocommerce/src/Admin/Features/MarketingRecommendations/DefaultMarketingRecommendations.php @@ -56,7 +56,7 @@ class DefaultMarketingRecommendations { return array( array( - 'title' => 'Google Listings and Ads', + 'title' => 'Google for WooCommerce', 'description' => __( 'Get in front of shoppers and drive traffic so you can grow your business with Smart Shopping Campaigns and free listings.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/google-listings-and-ads/{$utm_string}", 'direct_install' => true, diff --git a/plugins/woocommerce/src/Admin/Features/Navigation/CoreMenu.php b/plugins/woocommerce/src/Admin/Features/Navigation/CoreMenu.php index 5f18b2355a0..23df1306948 100644 --- a/plugins/woocommerce/src/Admin/Features/Navigation/CoreMenu.php +++ b/plugins/woocommerce/src/Admin/Features/Navigation/CoreMenu.php @@ -2,6 +2,7 @@ /** * WooCommerce Navigation Core Menu * + * @deprecated 9.3.0 Navigation is no longer a feature and its classes will be removed in WooCommerce 9.4. * @package Woocommerce Admin */ diff --git a/plugins/woocommerce/src/Admin/Features/Navigation/Favorites.php b/plugins/woocommerce/src/Admin/Features/Navigation/Favorites.php index 8c25424d302..e842bb713e2 100644 --- a/plugins/woocommerce/src/Admin/Features/Navigation/Favorites.php +++ b/plugins/woocommerce/src/Admin/Features/Navigation/Favorites.php @@ -2,6 +2,7 @@ /** * WooCommerce Navigation Favorite * + * @deprecated 9.3.0 Navigation is no longer a feature and its classes will be removed in WooCommerce 9.4. * @package Woocommerce Navigation */ diff --git a/plugins/woocommerce/src/Admin/Features/Navigation/Init.php b/plugins/woocommerce/src/Admin/Features/Navigation/Init.php index 2d0cd85142e..4ce972e6fe7 100644 --- a/plugins/woocommerce/src/Admin/Features/Navigation/Init.php +++ b/plugins/woocommerce/src/Admin/Features/Navigation/Init.php @@ -2,17 +2,17 @@ /** * Navigation Experience * + * @deprecated 9.3.0 Navigation is no longer a feature and its classes will be removed in WooCommerce 9.4. * @package Woocommerce Admin */ namespace Automattic\WooCommerce\Admin\Features\Navigation; -use Automattic\WooCommerce\Internal\Admin\Survey; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Features\Navigation\Screen; use Automattic\WooCommerce\Admin\Features\Navigation\Menu; use Automattic\WooCommerce\Admin\Features\Navigation\CoreMenu; -use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; +use WC_Tracks; /** * Contains logic for the Navigation @@ -23,115 +23,27 @@ class Init { */ const TOGGLE_OPTION_NAME = 'woocommerce_navigation_enabled'; - /** - * Determines if the feature has been toggled on or off. - * - * @var boolean - */ - protected static $is_updated = false; - /** * Hook into WooCommerce. */ public function __construct() { - add_action( 'update_option_' . self::TOGGLE_OPTION_NAME, array( $this, 'reload_page_on_toggle' ), 10, 2 ); - add_action( 'woocommerce_settings_saved', array( $this, 'maybe_reload_page' ) ); - add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_opt_out_scripts' ) ); - if ( Features::is_enabled( 'navigation' ) ) { - Menu::instance()->init(); - CoreMenu::instance()->init(); - Screen::instance()->init(); + // Disable the option to turn off the feature. + update_option( self::TOGGLE_OPTION_NAME, 'no' ); + + if ( class_exists( 'WC_Tracks' ) ) { + WC_Tracks::record_event( 'deprecated_navigation_in_use' ); + } } } /** - * Add the feature toggle to the features settings. + * Create a deprecation notice. * - * @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class). - * - * @param array $features Feature sections. - * @return array + * @param string $fcn The function that is deprecated. */ - public static function add_feature_toggle( $features ) { - return $features; - } - - /** - * Determine if sufficient versions are present to support Navigation feature - */ - public function is_nav_compatible() { - include_once ABSPATH . 'wp-admin/includes/plugin.php'; - - $gutenberg_minimum_version = '9.0.0'; // https://github.com/WordPress/gutenberg/releases/tag/v9.0.0. - $wp_minimum_version = '5.6'; - $has_gutenberg = is_plugin_active( 'gutenberg/gutenberg.php' ); - $gutenberg_version = $has_gutenberg ? get_plugin_data( WP_PLUGIN_DIR . '/gutenberg/gutenberg.php' )['Version'] : false; - - if ( $gutenberg_version && version_compare( $gutenberg_version, $gutenberg_minimum_version, '>=' ) ) { - return true; - } - - // Get unmodified $wp_version. - include ABSPATH . WPINC . '/version.php'; - - // Strip '-src' from the version string. Messes up version_compare(). - $wp_version = str_replace( '-src', '', $wp_version ); - - if ( version_compare( $wp_version, $wp_minimum_version, '>=' ) ) { - return true; - } - - return false; - } - - /** - * Reloads the page when the option is toggled to make sure all nav features are loaded. - * - * @param string $old_value Old value. - * @param string $value New value. - */ - public static function reload_page_on_toggle( $old_value, $value ) { - if ( $old_value === $value ) { - return; - } - - if ( 'yes' !== $value ) { - update_option( 'woocommerce_navigation_show_opt_out', 'yes' ); - } - - self::$is_updated = true; - } - - /** - * Reload the page if the setting has been updated. - */ - public static function maybe_reload_page() { - if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! self::$is_updated ) { - return; - } - - wp_safe_redirect( wp_unslash( $_SERVER['REQUEST_URI'] ) ); - exit(); - } - - /** - * Enqueue the opt out scripts. - */ - public function maybe_enqueue_opt_out_scripts() { - if ( get_option( 'woocommerce_navigation_show_opt_out', 'no' ) !== 'yes' ) { - return; - } - - WCAdminAssets::register_style( 'navigation-opt-out', 'style', array( 'wp-components' ) ); - WCAdminAssets::register_script( 'wp-admin-scripts', 'navigation-opt-out', true ); - wp_localize_script( - 'wc-admin-navigation-opt-out', - 'surveyData', - array( - 'url' => Survey::get_url( '/new-navigation-opt-out' ), - ) - ); - delete_option( 'woocommerce_navigation_show_opt_out' ); + public static function deprecation_notice( $fcn ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'Automattic\WooCommerce\Admin\Features\Navigation\\' . $fcn . ' is deprecated since 9.3 with no alternative. Navigation classes will be removed in WooCommerce 9.4' ); } } diff --git a/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php b/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php index 486b34c35e2..42afe8892c5 100644 --- a/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php +++ b/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php @@ -2,6 +2,7 @@ /** * WooCommerce Navigation Menu * + * @deprecated 9.3.0 Navigation is no longer a feature and its classes will be removed in WooCommerce 9.4. * @package Woocommerce Navigation */ @@ -10,6 +11,7 @@ namespace Automattic\WooCommerce\Admin\Features\Navigation; use Automattic\WooCommerce\Admin\Features\Navigation\Favorites; use Automattic\WooCommerce\Admin\Features\Navigation\Screen; use Automattic\WooCommerce\Admin\Features\Navigation\CoreMenu; +use Automattic\WooCommerce\Admin\Features\Navigation\Init; /** * Contains logic for the WooCommerce Navigation menu. @@ -95,172 +97,33 @@ class Menu { /** * Init. + * + * @internal */ - public function init() { - add_action( 'admin_menu', array( $this, 'add_core_items' ), 100 ); - add_filter( 'admin_enqueue_scripts', array( $this, 'enqueue_data' ), 20 ); - - add_filter( 'admin_menu', array( $this, 'migrate_core_child_items' ), PHP_INT_MAX - 1 ); - add_filter( 'admin_menu', array( $this, 'migrate_menu_items' ), PHP_INT_MAX - 2 ); - } + final public function init() {} /** * Convert a WordPress menu callback to a URL. - * - * @param string $callback Menu callback. - * @return string */ - public static function get_callback_url( $callback ) { - // Return the full URL. - if ( strpos( $callback, 'http' ) === 0 ) { - return $callback; - } - - $pos = strpos( $callback, '?' ); - $file = $pos > 0 ? substr( $callback, 0, $pos ) : $callback; - if ( file_exists( ABSPATH . "/wp-admin/$file" ) ) { - return $callback; - } - return 'admin.php?page=' . $callback; - } + public static function get_callback_url() {} /** * Get the parent key if one exists. - * - * @param string $callback Callback or URL. - * @return string|null */ - public static function get_parent_key( $callback ) { - global $submenu; - - if ( ! $submenu ) { - return null; - } - - // This is already a parent item. - if ( isset( $submenu[ $callback ] ) ) { - return null; - } - - foreach ( $submenu as $key => $menu ) { - foreach ( $menu as $item ) { - if ( $item[ self::CALLBACK ] === $callback ) { - return $key; - } - } - } - - return null; - } + public static function get_parent_key() {} /** * Adds a top level menu item to the navigation. - * - * @param array $args Array containing the necessary arguments. - * $args = array( - * 'id' => (string) The unique ID of the menu item. Required. - * 'title' => (string) Title of the menu item. Required. - * 'url' => (string) URL or callback to be used. Required. - * 'order' => (int) Menu item order. - * 'migrate' => (bool) Whether or not to hide the item in the wp admin menu. - * 'menuId' => (string) The ID of the menu to add the category to. - * ). */ - private static function add_category( $args ) { - if ( ! isset( $args['id'] ) || isset( self::$menu_items[ $args['id'] ] ) ) { - return; - } - - $defaults = array( - 'id' => '', - 'title' => '', - 'order' => 100, - 'migrate' => true, - 'menuId' => 'primary', - 'isCategory' => true, - ); - $menu_item = wp_parse_args( $args, $defaults ); - $menu_item['title'] = wp_strip_all_tags( wp_specialchars_decode( $menu_item['title'] ) ); - unset( $menu_item['url'] ); - unset( $menu_item['capability'] ); - - if ( ! isset( $menu_item['parent'] ) ) { - $menu_item['parent'] = 'woocommerce'; - $menu_item['backButtonLabel'] = __( - 'WooCommerce Home', - 'woocommerce' - ); - } - - self::$menu_items[ $menu_item['id'] ] = $menu_item; - self::$categories[ $menu_item['id'] ] = array(); - self::$categories[ $menu_item['parent'] ][] = $menu_item['id']; - - if ( isset( $args['url'] ) ) { - self::$callbacks[ $args['url'] ] = $menu_item['migrate']; - } + private static function add_category() { + Init::deprecation_notice( 'Menu::add_category' ); } /** * Adds a child menu item to the navigation. - * - * @param array $args Array containing the necessary arguments. - * $args = array( - * 'id' => (string) The unique ID of the menu item. Required. - * 'title' => (string) Title of the menu item. Required. - * 'parent' => (string) Parent menu item ID. - * 'capability' => (string) Capability to view this menu item. - * 'url' => (string) URL or callback to be used. Required. - * 'order' => (int) Menu item order. - * 'migrate' => (bool) Whether or not to hide the item in the wp admin menu. - * 'menuId' => (string) The ID of the menu to add the item to. - * 'matchExpression' => (string) A regular expression used to identify if the menu item is active. - * ). */ - private static function add_item( $args ) { - if ( ! isset( $args['id'] ) ) { - return; - } - - if ( isset( self::$menu_items[ $args['id'] ] ) ) { - wc_doing_it_wrong( - __METHOD__, - sprintf( - /* translators: 1: Duplicate menu item path. */ - esc_html__( 'You have attempted to register a duplicate item with WooCommerce Navigation: %1$s', 'woocommerce' ), - '`' . $args['id'] . '`' - ), - '6.5.0' - ); - - return; - } - - $defaults = array( - 'id' => '', - 'title' => '', - 'capability' => 'manage_woocommerce', - 'url' => '', - 'order' => 100, - 'migrate' => true, - 'menuId' => 'primary', - ); - $menu_item = wp_parse_args( $args, $defaults ); - $menu_item['title'] = wp_strip_all_tags( wp_specialchars_decode( $menu_item['title'] ) ); - $menu_item['url'] = self::get_callback_url( $menu_item['url'] ); - - if ( ! isset( $menu_item['parent'] ) ) { - $menu_item['parent'] = 'woocommerce'; - } - - $menu_item['menuId'] = self::get_item_menu_id( $menu_item ); - - self::$menu_items[ $menu_item['id'] ] = $menu_item; - self::$categories[ $menu_item['parent'] ][] = $menu_item['id']; - - if ( isset( $args['url'] ) ) { - self::$callbacks[ $args['url'] ] = $menu_item['migrate']; - } + private static function add_item() { + Init::deprecation_notice( 'Menu::add_item' ); } /** @@ -287,112 +150,25 @@ class Menu { /** * Adds a plugin category. - * - * @param array $args Array containing the necessary arguments. - * $args = array( - * 'id' => (string) The unique ID of the menu item. Required. - * 'title' => (string) Title of the menu item. Required. - * 'url' => (string) URL or callback to be used. Required. - * 'migrate' => (bool) Whether or not to hide the item in the wp admin menu. - * 'order' => (int) Menu item order. - * ). */ - public static function add_plugin_category( $args ) { - $category_args = array_merge( - $args, - array( - 'menuId' => 'plugins', - ) - ); - - if ( ! isset( $category_args['parent'] ) ) { - unset( $category_args['order'] ); - } - - $menu_id = self::get_item_menu_id( $category_args ); - if ( ! in_array( $menu_id, array( 'plugins', 'favorites' ), true ) ) { - return; - } - - $category_args['menuId'] = $menu_id; - - self::add_category( $category_args ); + public static function add_plugin_category() { + Init::deprecation_notice( 'Menu::add_plugin_category' ); } /** * Adds a plugin item. - * - * @param array $args Array containing the necessary arguments. - * $args = array( - * 'id' => (string) The unique ID of the menu item. Required. - * 'title' => (string) Title of the menu item. Required. - * 'parent' => (string) Parent menu item ID. - * 'capability' => (string) Capability to view this menu item. - * 'url' => (string) URL or callback to be used. Required. - * 'migrate' => (bool) Whether or not to hide the item in the wp admin menu. - * 'order' => (int) Menu item order. - * 'matchExpression' => (string) A regular expression used to identify if the menu item is active. - * ). */ - public static function add_plugin_item( $args ) { - if ( ! isset( $args['parent'] ) ) { - unset( $args['order'] ); - } - - $item_args = array_merge( - $args, - array( - 'menuId' => 'plugins', - ) - ); - - $menu_id = self::get_item_menu_id( $item_args ); - - if ( 'plugins' !== $menu_id ) { - return; - } - - self::add_item( $item_args ); + public static function add_plugin_item() { + Init::deprecation_notice( 'Menu::add_plugin_item' ); } /** * Adds a plugin setting item. - * - * @param array $args Array containing the necessary arguments. - * $args = array( - * 'id' => (string) The unique ID of the menu item. Required. - * 'title' => (string) Title of the menu item. Required. - * 'capability' => (string) Capability to view this menu item. - * 'url' => (string) URL or callback to be used. Required. - * 'migrate' => (bool) Whether or not to hide the item in the wp admin menu. - * ). */ - public static function add_setting_item( $args ) { - unset( $args['order'] ); - - if ( isset( $args['parent'] ) || isset( $args['menuId'] ) ) { - error_log( // phpcs:ignore - sprintf( - /* translators: 1: Duplicate menu item path. */ - esc_html__( 'The item ID %1$s attempted to register using an invalid option. The arguments `menuId` and `parent` are not allowed for add_setting_item()', 'woocommerce' ), - '`' . $args['id'] . '`' - ) - ); - } - - $item_args = array_merge( - $args, - array( - 'menuId' => 'secondary', - 'parent' => 'woocommerce-settings', - ) - ); - - self::add_item( $item_args ); + public static function add_setting_item() { + Init::deprecation_notice( 'Menu::add_setting_item' ); } - - /** * Get menu item templates for a given post type. * diff --git a/plugins/woocommerce/src/Admin/Features/Navigation/Screen.php b/plugins/woocommerce/src/Admin/Features/Navigation/Screen.php index 7f54d110bd4..dfe6e60e12e 100644 --- a/plugins/woocommerce/src/Admin/Features/Navigation/Screen.php +++ b/plugins/woocommerce/src/Admin/Features/Navigation/Screen.php @@ -2,12 +2,14 @@ /** * WooCommerce Navigation Screen * + * @deprecated 9.3.0 Navigation is no longer a feature and its classes will be removed in WooCommerce 9.4. * @package Woocommerce Navigation */ namespace Automattic\WooCommerce\Admin\Features\Navigation; use Automattic\WooCommerce\Admin\Features\Navigation\Menu; +use Automattic\WooCommerce\Admin\Features\Navigation\Init; /** * Contains logic for the WooCommerce Navigation menu. @@ -85,6 +87,8 @@ class Screen { * @return bool */ public static function is_woocommerce_page() { + Init::deprecation_notice( 'Screen::is_woocommerce_page' ); + global $pagenow; // Get taxonomy if on a taxonomy screen. @@ -218,23 +222,15 @@ class Screen { /** * Register post type for use in WooCommerce Navigation screens. - * - * @param string $post_type Post type to add. */ - public static function register_post_type( $post_type ) { - if ( ! in_array( $post_type, self::$post_types, true ) ) { - self::$post_types[] = $post_type; - } + public static function register_post_type() { + Init::deprecation_notice( 'Screen::register_post_type' ); } /** * Register taxonomy for use in WooCommerce Navigation screens. - * - * @param string $taxonomy Taxonomy to add. */ - public static function register_taxonomy( $taxonomy ) { - if ( ! in_array( $taxonomy, self::$taxonomies, true ) ) { - self::$taxonomies[] = $taxonomy; - } + public static function register_taxonomy() { + Init::deprecation_notice( 'Screen::register_taxonomy' ); } } diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Task.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Task.php index cfc54d4569c..063e01fd1a5 100644 --- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Task.php +++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Task.php @@ -61,7 +61,7 @@ abstract class Task { protected $task_list; /** - * Duration to milisecond mapping. + * Duration to millisecond mapping. * * @var string */ diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/TaskLists.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/TaskLists.php index 66203a86b0d..a60515131c6 100644 --- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/TaskLists.php +++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/TaskLists.php @@ -117,7 +117,6 @@ class TaskLists { 'Payments', 'Tax', 'Shipping', - 'Marketing', 'LaunchYourStore', ); @@ -165,6 +164,7 @@ class TaskLists { ), ), 'tasks' => array( + 'Marketing', 'ExtendStore', 'AdditionalPayments', 'GetMobileApp', @@ -297,7 +297,6 @@ class TaskLists { $task_list->add_task( $task ); } } - } /** @@ -318,8 +317,8 @@ class TaskLists { public static function get_lists_by_ids( $ids ) { return array_filter( self::$lists, - function( $list ) use ( $ids ) { - return in_array( $list->get_list_id(), $ids, true ); + function ( $task_list ) use ( $ids ) { + return in_array( $task_list->get_list_id(), $ids, true ); } ); } @@ -404,25 +403,31 @@ class TaskLists { /** * Return number of setup tasks remaining * - * @return number + * This is not updated immediately when a task is completed, but rather when task is marked as complete in the database to reduce performance impact. + * + * @return int|null */ public static function setup_tasks_remaining() { $setup_list = self::get_list( 'setup' ); - if ( ! $setup_list || $setup_list->is_hidden() || $setup_list->has_previously_completed() || $setup_list->is_complete() ) { + if ( ! $setup_list || $setup_list->is_hidden() || $setup_list->has_previously_completed() ) { return; } - $remaining_tasks = array_values( + $viewable_tasks = $setup_list->get_viewable_tasks(); + $completed_tasks = get_option( Task::COMPLETED_OPTION, array() ); + if ( ! is_array( $completed_tasks ) ) { + $completed_tasks = array(); + } + + return count( array_filter( - $setup_list->get_viewable_tasks(), - function( $task ) { - return ! $task->is_complete(); + $viewable_tasks, + function ( $task ) use ( $completed_tasks ) { + return ! in_array( $task->get_id(), $completed_tasks, true ); } ) ); - - return count( $remaining_tasks ); } /** @@ -443,7 +448,6 @@ class TaskLists { break; } } - } /** diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/AdditionalPayments.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/AdditionalPayments.php index 672b9a4a932..f5cdd68fedd 100644 --- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/AdditionalPayments.php +++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/AdditionalPayments.php @@ -188,7 +188,7 @@ class AdditionalPayments extends Payments { */ private static function get_suggestion_gateways( $filter_by = 'category_additional' ) { $country = wc_get_base_location()['country']; - $plugin_suggestions = Init::get_suggestions(); + $plugin_suggestions = Init::get_cached_or_default_suggestions(); $plugin_suggestions = array_filter( $plugin_suggestions, function( $plugin ) use ( $country, $filter_by ) { diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/CustomizeStore.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/CustomizeStore.php index 432bd4e20e4..fe6a2f28b7a 100644 --- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/CustomizeStore.php +++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/CustomizeStore.php @@ -3,7 +3,7 @@ namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; -use Jetpack_Gutenberg; +use WP_Post; /** * Customize Your Store Task @@ -24,23 +24,24 @@ class CustomizeStore extends Task { // Hook to remove unwanted UI elements when users are viewing with ?cys-hide-admin-bar=true. add_action( 'wp_head', array( $this, 'possibly_remove_unwanted_ui_elements' ) ); - add_action( 'save_post_wp_global_styles', array( $this, 'mark_task_as_complete' ), 10, 3 ); - add_action( 'save_post_wp_template', array( $this, 'mark_task_as_complete' ), 10, 3 ); - add_action( 'save_post_wp_template_part', array( $this, 'mark_task_as_complete' ), 10, 3 ); + add_action( 'save_post_wp_global_styles', array( $this, 'mark_task_as_complete_block_theme' ), 10, 3 ); + add_action( 'save_post_wp_template', array( $this, 'mark_task_as_complete_block_theme' ), 10, 3 ); + add_action( 'save_post_wp_template_part', array( $this, 'mark_task_as_complete_block_theme' ), 10, 3 ); + add_action( 'customize_save_after', array( $this, 'mark_task_as_complete_classic_theme' ) ); } /** * Mark the CYS task as complete whenever the user updates their global styles. * - * @param int $post_id Post ID. - * @param \WP_Post $post Post object. - * @param bool $update Whether this is an existing post being updated. + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @param bool $update Whether this is an existing post being updated. * * @return void */ - public function mark_task_as_complete( $post_id, $post, $update ) { - if ( $post instanceof \WP_Post ) { - $is_cys_complete = '{"version": 2, "isGlobalStylesUserThemeJSON": true }' !== $post->post_content || in_array( $post->post_type, array( 'wp_template', 'wp_template_part' ), true ); + public function mark_task_as_complete_block_theme( $post_id, $post, $update ) { + if ( $post instanceof WP_Post ) { + $is_cys_complete = $this->has_custom_global_styles( $post ) || $this->has_custom_template( $post ); if ( $is_cys_complete ) { update_option( 'woocommerce_admin_customize_store_completed', 'yes' ); @@ -48,6 +49,15 @@ class CustomizeStore extends Task { } } + /** + * Mark the CYS task as complete whenever the user saves the customizer changes. + * + * @return void + */ + public function mark_task_as_complete_classic_theme() { + update_option( 'woocommerce_admin_customize_store_completed', 'yes' ); + } + /** * ID. * @@ -227,11 +237,6 @@ class CustomizeStore extends Task { * @since 8.0.3 */ do_action( 'enqueue_block_editor_assets' ); - - // Load Jetpack's block editor assets because they are not enqueued by default. - if ( class_exists( 'Jetpack_Gutenberg' ) ) { - Jetpack_Gutenberg::enqueue_block_editor_assets(); - } } /** @@ -260,4 +265,33 @@ class CustomizeStore extends Task { '; } } + + /** + * Checks if the post has custom global styles stored (if it is different from the default global styles). + * + * @param WP_Post $post The post object. + * @return bool + */ + private function has_custom_global_styles( WP_Post $post ) { + $required_keys = array( 'version', 'isGlobalStylesUserThemeJSON' ); + + $json_post_content = json_decode( $post->post_content, true ); + if ( is_null( $json_post_content ) ) { + return false; + } + + $post_content_keys = array_keys( $json_post_content ); + + return ! empty( array_diff( $post_content_keys, $required_keys ) ) || ! empty( array_diff( $required_keys, $post_content_keys ) ); + } + + /** + * Checks if the post is a template or a template part. + * + * @param WP_Post $post The post object. + * @return bool Whether the post is a template or a template part. + */ + private function has_custom_template( WP_Post $post ) { + return in_array( $post->post_type, array( 'wp_template', 'wp_template_part' ), true ); + } } diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Marketing.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Marketing.php index d50b387e9ad..347e8986f2d 100644 --- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Marketing.php +++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Marketing.php @@ -56,79 +56,40 @@ class Marketing extends Task { return __( '2 minutes', 'woocommerce' ); } - /** - * Task completion. - * - * @return bool - */ - public function is_complete() { - if ( null === $this->is_complete_result ) { - $this->is_complete_result = self::has_installed_extensions(); - } - - return $this->is_complete_result; - } - /** * Task visibility. * * @return bool */ public function can_view() { - return Features::is_enabled( 'remote-free-extensions' ) && count( self::get_plugins() ) > 0; + return Features::is_enabled( 'remote-free-extensions' ); } /** * Get the marketing plugins. * + * @deprecated 9.3.0 Removed to improve performance. * @return array */ public static function get_plugins() { - $bundles = RemoteFreeExtensions::get_extensions( - array( - 'task-list/reach', - 'task-list/grow', - ) - ); - - return array_reduce( - $bundles, - function( $plugins, $bundle ) { - $visible = array(); - foreach ( $bundle['plugins'] as $plugin ) { - if ( $plugin->is_visible ) { - $visible[] = $plugin; - } - } - return array_merge( $plugins, $visible ); - }, - array() + wc_deprecated_function( + __METHOD__, + '9.3.0' ); + return array(); } /** * Check if the store has installed marketing extensions. * + * @deprecated 9.3.0 Removed to improve performance. * @return bool */ public static function has_installed_extensions() { - $plugins = self::get_plugins(); - $remaining = array(); - $installed = array(); - - foreach ( $plugins as $plugin ) { - if ( ! $plugin->is_installed ) { - $remaining[] = $plugin; - } else { - $installed[] = $plugin; - } - } - - // Make sure the task has been actioned and a marketing extension has been installed. - if ( count( $installed ) > 0 && Task::is_task_actioned( 'marketing' ) ) { - return true; - } - + wc_deprecated_function( + __METHOD__, + '9.3.0' + ); return false; } } diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php index 662d4fec80b..ba33d9b2362 100644 --- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php +++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php @@ -121,7 +121,7 @@ class Tax extends Task { } /** - * Addtional data. + * Additional data. * * @return array */ @@ -129,9 +129,11 @@ class Tax extends Task { return array( 'avalara_activated' => PluginsHelper::is_plugin_active( 'woocommerce-avatax' ), 'tax_jar_activated' => class_exists( 'WC_Taxjar' ), + 'stripe_tax_activated' => PluginsHelper::is_plugin_active( 'stripe-tax-for-woocommerce' ), 'woocommerce_tax_activated' => PluginsHelper::is_plugin_active( 'woocommerce-tax' ), 'woocommerce_shipping_activated' => PluginsHelper::is_plugin_active( 'woocommerce-shipping' ), 'woocommerce_tax_countries' => self::get_automated_support_countries(), + 'stripe_tax_countries' => self::get_stripe_tax_support_countries(), ); } @@ -162,4 +164,54 @@ class Tax extends Task { return $tax_supported_countries; } + + /** + * Get an array of countries that support Stripe tax. + * + * @return array + */ + private static function get_stripe_tax_support_countries() { + // https://docs.stripe.com/tax/supported-countries#supported-countries accurate as of 2024-08-26. + // countries with remote sales not included. + return array( + 'AU', + 'AT', + 'BE', + 'BG', + 'CA', + 'HR', + 'CY', + 'CZ', + 'DK', + 'EE', + 'FI', + 'FR', + 'DE', + 'GR', + 'HK', + 'HU', + 'IE', + 'IT', + 'JP', + 'LV', + 'LT', + 'LU', + 'MT', + 'NL', + 'NZ', + 'NO', + 'PL', + 'PT', + 'RO', + 'SG', + 'SK', + 'SI', + 'ES', + 'SE', + 'CH', + 'AE', + 'GB', + 'US', + ); + } } diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php index f75656d38df..5c6d45255f6 100644 --- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php +++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php @@ -7,6 +7,7 @@ use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; use Automattic\WooCommerce\Admin\PluginsHelper; use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\Init as Suggestions; use Automattic\WooCommerce\Internal\Admin\WCPayPromotion\Init as WCPayPromotionInit; +use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways; /** * WooCommercePayments Task @@ -146,11 +147,9 @@ class WooCommercePayments extends Task { * @return bool */ public static function is_connected() { - if ( class_exists( '\WC_Payments' ) ) { - $wc_payments_gateway = \WC_Payments::get_gateway(); - return method_exists( $wc_payments_gateway, 'is_connected' ) - ? $wc_payments_gateway->is_connected() - : false; + $wc_payments_gateway = self::get_woo_payments_gateway(); + if ( $wc_payments_gateway && method_exists( $wc_payments_gateway, 'is_connected' ) ) { + return $wc_payments_gateway->is_connected(); } return false; @@ -163,11 +162,9 @@ class WooCommercePayments extends Task { * @return bool */ public static function is_account_partially_onboarded() { - if ( class_exists( '\WC_Payments' ) ) { - $wc_payments_gateway = \WC_Payments::get_gateway(); - return method_exists( $wc_payments_gateway, 'is_account_partially_onboarded' ) - ? $wc_payments_gateway->is_account_partially_onboarded() - : false; + $wc_payments_gateway = self::get_woo_payments_gateway(); + if ( $wc_payments_gateway && method_exists( $wc_payments_gateway, 'is_account_partially_onboarded' ) ) { + return $wc_payments_gateway->is_account_partially_onboarded(); } return false; @@ -179,11 +176,11 @@ class WooCommercePayments extends Task { * @return bool */ public static function is_supported() { - $suggestions = Suggestions::get_suggestions(); + $suggestions = Suggestions::get_suggestions( DefaultPaymentGateways::get_all() ); $suggestion_plugins = array_merge( ...array_filter( array_column( $suggestions, 'plugins' ), - function( $plugins ) { + function ( $plugins ) { return is_array( $plugins ); } ) @@ -194,4 +191,17 @@ class WooCommercePayments extends Task { } return false; } + + /** + * Get the WooPayments gateway. + * + * @return \WC_Payments|null + */ + private static function get_woo_payments_gateway() { + $payment_gateways = WC()->payment_gateways->payment_gateways(); + if ( isset( $payment_gateways['woocommerce_payments'] ) ) { + return $payment_gateways['woocommerce_payments']; + } + return null; + } } diff --git a/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/DefaultPaymentGateways.php b/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/DefaultPaymentGateways.php index 001f3ae30dd..d80271c985d 100644 --- a/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/DefaultPaymentGateways.php +++ b/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/DefaultPaymentGateways.php @@ -65,6 +65,13 @@ class DefaultPaymentGateways { 'CA', ) ), + (object) array( + 'type' => 'or', + 'operands' => array( + self::get_rules_for_wcpay_activated( false ), + self::get_rules_for_wcpay_connected( false ), + ), + ), ), 'category_other' => array(), 'category_additional' => array( @@ -87,6 +94,13 @@ class DefaultPaymentGateways { 'AU', ) ), + (object) array( + 'type' => 'or', + 'operands' => array( + self::get_rules_for_wcpay_activated( false ), + self::get_rules_for_wcpay_connected( false ), + ), + ), ), 'category_other' => array(), 'category_additional' => array( @@ -250,6 +264,19 @@ class DefaultPaymentGateways { ) ), self::get_rules_for_cbd( false ), + (object) array( + 'type' => 'or', + 'operands' => array( + (object) array( + 'type' => 'not', + 'operand' => array( + self::get_rules_for_countries( self::get_wcpay_countries() ), + ), + ), + self::get_rules_for_wcpay_activated( false ), + self::get_rules_for_wcpay_connected( false ), + ), + ), ), 'category_other' => array(), 'category_additional' => array( @@ -862,6 +889,47 @@ class DefaultPaymentGateways { ), ), ), + array( + 'id' => 'woocommerce_payments:bnpl', + 'title' => __( 'Activate BNPL instantly on WooPayments', 'woocommerce' ), + 'content' => __( + 'The world’s favorite buy now, pay later options and many more are right at your fingertips with WooPayments — all from one dashboard, without needing multiple extensions and logins.', + 'woocommerce' + ), + 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay-bnpl.svg', + 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay-bnpl.svg', + 'plugins' => array( 'woocommerce-payments' ), + 'is_visible' => array( + self::get_rules_for_countries( + array_intersect( + array( + 'US', + 'CA', + 'AU', + 'AT', + 'BE', + 'CH', + 'DK', + 'ES', + 'FI', + 'FR', + 'DE', + 'GB', + 'IT', + 'NL', + 'NO', + 'PL', + 'SE', + 'NZ', + ), + self::get_wcpay_countries() + ), + ), + self::get_rules_for_cbd( false ), + self::get_rules_for_wcpay_activated( true ), + self::get_rules_for_wcpay_connected( true ), + ), + ), array( 'id' => 'zipmoney', 'title' => __( 'Zip Co - Buy Now, Pay Later', 'woocommerce' ), @@ -976,7 +1044,7 @@ class DefaultPaymentGateways { * Get default rules for CBD based on given argument. * * @param bool $should_have Whether or not the store should have CBD as an industry (true) or not (false). - * @return array Rules to match. + * @return object Rules to match. */ public static function get_rules_for_cbd( $should_have ) { return (object) array( @@ -1002,6 +1070,62 @@ class DefaultPaymentGateways { ); } + /** + * Get default rules for the WooPayments plugin being installed and activated. + * + * @param bool $should_be Whether WooPayments should be activated. + * + * @return object Rules to match. + */ + public static function get_rules_for_wcpay_activated( $should_be ) { + $active_rule = (object) array( + 'type' => 'plugins_activated', + 'plugins' => array( 'woocommerce-payments' ), + ); + + if ( $should_be ) { + return $active_rule; + } + + return (object) array( + 'type' => 'not', + 'operand' => array( $active_rule ), + ); + } + + /** + * Get default rules for WooPayments being connected or not. + * + * This does not include the check for the WooPayments plugin to be active. + * + * @param bool $should_be Whether WooPayments should be connected. + * + * @return object Rules to match. + */ + public static function get_rules_for_wcpay_connected( $should_be ) { + return (object) array( + 'type' => 'option', + 'transformers' => array( + // Extract only the 'data' key from the option. + (object) array( + 'use' => 'dot_notation', + 'arguments' => (object) array( + 'path' => 'data', + ), + ), + // Extract the keys from the data array. + (object) array( + 'use' => 'array_keys', + ), + ), + 'option_name' => 'wcpay_account_data', + // The rule will be look for the 'account_id' key in the account data array. + 'operation' => $should_be ? 'contains' : '!contains', + 'value' => 'account_id', + 'default' => array(), + ); + } + /** * Get recommendation priority for a given payment gateway by id and country. * If country is not supported, return null. @@ -1013,6 +1137,8 @@ class DefaultPaymentGateways { private static function get_recommendation_priority( $gateway_id, $country_code ) { $recommendation_priority_map = array( 'US' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1023,6 +1149,8 @@ class DefaultPaymentGateways { 'klarna_payments', ), 'CA' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1032,6 +1160,8 @@ class DefaultPaymentGateways { 'klarna_payments', ), 'AT' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1041,6 +1171,8 @@ class DefaultPaymentGateways { 'amazon_payments_advanced', ), 'BE' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1049,26 +1181,63 @@ class DefaultPaymentGateways { 'klarna_payments', 'amazon_payments_advanced', ), - 'BG' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway' ), - 'HR' => array( 'woocommerce_payments', 'ppcp-gateway' ), + 'BG' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + ), + 'HR' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'ppcp-gateway', + ), 'CH' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'mollie_wc_gateway_banktransfer', 'klarna_payments', ), - 'CY' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'amazon_payments_advanced' ), - 'CZ' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway' ), + 'CY' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + 'amazon_payments_advanced', + ), + 'CZ' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + ), 'DK' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'klarna_payments', 'amazon_payments_advanced', ), - 'EE' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main' ), + 'EE' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + 'airwallex_main', + ), 'ES' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1078,6 +1247,8 @@ class DefaultPaymentGateways { 'amazon_payments_advanced', ), 'FI' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1086,6 +1257,8 @@ class DefaultPaymentGateways { 'klarna_payments', ), 'FR' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1096,6 +1269,8 @@ class DefaultPaymentGateways { 'amazon_payments_advanced', ), 'DE' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1105,6 +1280,8 @@ class DefaultPaymentGateways { 'amazon_payments_advanced', ), 'GB' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1114,9 +1291,25 @@ class DefaultPaymentGateways { 'klarna_payments', 'amazon_payments_advanced', ), - 'GR' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main' ), - 'HU' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'amazon_payments_advanced' ), + 'GR' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + 'airwallex_main', + ), + 'HU' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + 'amazon_payments_advanced', + ), 'IE' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1125,6 +1318,8 @@ class DefaultPaymentGateways { 'amazon_payments_advanced', ), 'IT' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1133,11 +1328,38 @@ class DefaultPaymentGateways { 'klarna_payments', 'amazon_payments_advanced', ), - 'LV' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway' ), - 'LT' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway' ), - 'LU' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'amazon_payments_advanced' ), - 'MT' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway' ), + 'LV' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + ), + 'LT' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + ), + 'LU' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + 'amazon_payments_advanced', + ), + 'MT' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + ), 'NL' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1146,8 +1368,18 @@ class DefaultPaymentGateways { 'klarna_payments', 'amazon_payments_advanced', ), - 'NO' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'kco', 'klarna_payments' ), + 'NO' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + 'kco', + 'klarna_payments', + ), 'PL' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1156,16 +1388,39 @@ class DefaultPaymentGateways { 'klarna_payments', ), 'PT' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', 'amazon_payments_advanced', ), - 'RO' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway' ), - 'SK' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway' ), - 'SL' => array( 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'amazon_payments_advanced' ), + 'RO' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + ), + 'SK' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + ), + 'SL' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'ppcp-gateway', + 'amazon_payments_advanced', + ), 'SE' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', @@ -1194,6 +1449,8 @@ class DefaultPaymentGateways { 'UY' => array( 'woo-mercado-pago-custom', 'ppcp-gateway' ), 'VE' => array( 'ppcp-gateway' ), 'AU' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'airwallex_main', @@ -1203,6 +1460,8 @@ class DefaultPaymentGateways { 'klarna_payments', ), 'NZ' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'airwallex_main', @@ -1210,6 +1469,8 @@ class DefaultPaymentGateways { 'klarna_payments', ), 'HK' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'airwallex_main', @@ -1217,13 +1478,22 @@ class DefaultPaymentGateways { 'payoneer-checkout', ), 'JP' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'square_credit_card', 'amazon_payments_advanced', ), - 'SG' => array( 'woocommerce_payments', 'stripe', 'airwallex_main', 'ppcp-gateway' ), + 'SG' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + 'stripe', + 'airwallex_main', + 'ppcp-gateway', + ), 'CN' => array( 'airwallex_main', 'ppcp-gateway', 'payoneer-checkout' ), 'FJ' => array(), 'GU' => array(), @@ -1232,7 +1502,11 @@ class DefaultPaymentGateways { 'ZA' => array( 'payfast', 'paystack' ), 'NG' => array( 'paystack' ), 'GH' => array( 'paystack' ), - 'AE' => array( 'woocommerce_payments' ), + 'AE' => array( + 'woocommerce_payments:with-in-person-payments', + 'woocommerce_payments:without-in-person-payments', + 'woocommerce_payments', + ), ); // If the country code is not in the list, return default priority. diff --git a/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/EvaluateSuggestion.php b/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/EvaluateSuggestion.php index 662bbf5631c..45d609da940 100644 --- a/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/EvaluateSuggestion.php +++ b/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/EvaluateSuggestion.php @@ -16,15 +16,33 @@ class EvaluateSuggestion { /** * Evaluates the spec and returns the suggestion. * - * @param object|array $spec The suggestion to evaluate. + * @param object|array $spec The suggestion to evaluate. + * @param array $logger_args Optional. Arguments for the rule evaluator logger. + * * @return object The evaluated suggestion. */ - public static function evaluate( $spec ) { + public static function evaluate( $spec, $logger_args = array() ) { $rule_evaluator = new RuleEvaluator(); $suggestion = is_array( $spec ) ? (object) $spec : clone $spec; if ( isset( $suggestion->is_visible ) ) { - $is_visible = $rule_evaluator->evaluate( $suggestion->is_visible ); + // Determine the suggestion's logger slug. + $logger_slug = ! empty( $suggestion->id ) ? $suggestion->id : ''; + // If the suggestion has no ID, use the title to generate a slug. + if ( empty( $logger_slug ) ) { + $logger_slug = ! empty( $suggestion->title ) ? sanitize_title_with_dashes( trim( $suggestion->title ) ) : 'anonymous-suggestion'; + } + + // Evaluate the visibility of the suggestion. + $is_visible = $rule_evaluator->evaluate( + $suggestion->is_visible, + null, + array( + 'slug' => $logger_slug, + 'source' => $logger_args['source'] ?? 'wc-payment-gateway-suggestions', + ) + ); + $suggestion->is_visible = $is_visible; } @@ -35,15 +53,17 @@ class EvaluateSuggestion { * Evaluates the specs and returns the visible suggestions. * * @param array $specs payment suggestion spec array. + * @param array $logger_args Optional. Arguments for the rule evaluator logger. + * * @return array The visible suggestions and errors. */ - public static function evaluate_specs( $specs ) { + public static function evaluate_specs( $specs, $logger_args = array() ) { $suggestions = array(); $errors = array(); foreach ( $specs as $spec ) { try { - $suggestion = self::evaluate( $spec ); + $suggestion = self::evaluate( $spec, $logger_args ); if ( ! property_exists( $suggestion, 'is_visible' ) || $suggestion->is_visible ) { $suggestions[] = $suggestion; } diff --git a/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/Init.php b/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/Init.php index af5c9f7c52f..b6a7712aa18 100644 --- a/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/Init.php +++ b/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/Init.php @@ -7,8 +7,6 @@ namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions; defined( 'ABSPATH' ) || exit; -use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways; -use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\PaymentGatewaysController; use Automattic\WooCommerce\Admin\RemoteSpecs\RemoteSpecsEngine; /** @@ -63,6 +61,31 @@ class Init extends RemoteSpecsEngine { return $specs_to_return; } + /** + * Gets either cached or default suggestions. + * + * @return array + */ + public static function get_cached_or_default_suggestions() { + $specs = 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) + ? DefaultPaymentGateways::get_all() + : PaymentGatewaySuggestionsDataSourcePoller::get_instance()->get_cached_specs(); + + if ( ! is_array( $specs ) || 0 === count( $specs ) ) { + $specs = DefaultPaymentGateways::get_all(); + } + /** + * Allows filtering of payment gateway suggestion specs + * + * @since 6.4.0 + * + * @param array Gateway specs. + */ + $specs = apply_filters( 'woocommerce_admin_payment_gateway_suggestion_specs', $specs ); + $results = EvaluateSuggestion::evaluate_specs( $specs ); + return $results['suggestions']; + } + /** * Delete the specs transient. */ diff --git a/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/PaymentGatewaySuggestionsDataSourcePoller.php b/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/PaymentGatewaySuggestionsDataSourcePoller.php index 5e3608b635d..d53ac724afd 100644 --- a/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/PaymentGatewaySuggestionsDataSourcePoller.php +++ b/plugins/woocommerce/src/Admin/Features/PaymentGatewaySuggestions/PaymentGatewaySuggestionsDataSourcePoller.php @@ -2,7 +2,7 @@ namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions; -use Automattic\WooCommerce\Admin\DataSourcePoller; +use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller; /** * Specs data source poller class for payment gateway suggestions. diff --git a/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/Init.php b/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/Init.php index d9562e9a3e2..7e274815e91 100644 --- a/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/Init.php +++ b/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/Init.php @@ -127,7 +127,7 @@ class Init { $editor_settings = $this->get_product_editor_settings(); $script_handle = 'wc-admin-edit-product'; - wp_register_script( $script_handle, '', array(), '0.1.0', true ); + wp_register_script( $script_handle, '', array( 'wp-blocks' ), '0.1.0', true ); wp_enqueue_script( $script_handle ); wp_add_inline_script( $script_handle, @@ -172,7 +172,7 @@ class Init { if ( ! PageController::is_admin_page() ) { return; } - // Dequeing this to avoid conflicts, until we remove the 'woocommerce-page' class. + // Dequeuing this to avoid conflicts, until we remove the 'woocommerce-page' class. wp_dequeue_style( 'woocommerce-blocktheme' ); } diff --git a/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/ProductFormsController.php b/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/ProductFormsController.php index d5b586d7ad0..f9fc30bc84e 100644 --- a/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/ProductFormsController.php +++ b/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/ProductFormsController.php @@ -59,7 +59,7 @@ class ProductFormsController { } /** - * Create ot update a product_form post for each product form template. + * Create or update a product_form post for each product form template. * If the post already exists, it will be updated. * If the post does not exist, it will be created even if the action is `update`. * diff --git a/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/RedirectionController.php b/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/RedirectionController.php index c5e2e135243..be9cb5e4d61 100644 --- a/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/RedirectionController.php +++ b/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/RedirectionController.php @@ -96,7 +96,7 @@ class RedirectionController { /** * Check if a product is supported by the new experience. * - * @param array $product_templates The registered product teamplates. + * @param array $product_templates The registered product templates. */ public function set_product_templates( array $product_templates ): void { $this->product_templates = $product_templates; diff --git a/plugins/woocommerce/src/Admin/Features/ProductDataViews/Init.php b/plugins/woocommerce/src/Admin/Features/ProductDataViews/Init.php new file mode 100644 index 00000000000..26909eff3ce --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/ProductDataViews/Init.php @@ -0,0 +1,145 @@ +has_data_views_support() ) { + add_action( 'admin_menu', array( $this, 'woocommerce_add_new_products_dashboard' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); + + if ( $this->is_product_data_view_page() ) { + add_filter( + 'admin_body_class', + static function ( $classes ) { + return "$classes is-fullscreen-mode"; + } + ); + } + } + } + + /** + * Returns true if we are on a JS powered admin page. + */ + private static function is_product_data_view_page() { + // phpcs:disable WordPress.Security.NonceVerification + return isset( $_GET['page'] ) && 'woocommerce-products-dashboard' === $_GET['page']; + // phpcs:enable WordPress.Security.NonceVerification + } + + /** + * Checks for data views support. + */ + private function has_data_views_support() { + if ( Utils::wp_version_compare( '6.6', '>=' ) ) { + return true; + } + + if ( is_plugin_active( 'gutenberg/gutenberg.php' ) ) { + $gutenberg_version = ''; + + if ( defined( 'GUTENBERG_VERSION' ) ) { + $gutenberg_version = GUTENBERG_VERSION; + } + + if ( ! $gutenberg_version ) { + $gutenberg_data = get_file_data( + WP_PLUGIN_DIR . '/gutenberg/gutenberg.php', + array( 'Version' => 'Version' ) + ); + $gutenberg_version = $gutenberg_data['Version']; + } + return version_compare( $gutenberg_version, '19.0', '>=' ); + } + + return false; + } + + /** + * Enqueue styles needed for the rich text editor. + */ + public function enqueue_styles() { + if ( ! $this->is_product_data_view_page() ) { + return; + } + wp_enqueue_style( 'wc-product-editor' ); + } + + /** + * Enqueue scripts needed for the product form block editor. + */ + public function enqueue_scripts() { + if ( ! $this->is_product_data_view_page() ) { + return; + } + + $script_handle = 'wc-admin-edit-product'; + wp_register_script( $script_handle, '', array( 'wp-blocks' ), '0.1.0', true ); + wp_enqueue_script( $script_handle ); + wp_enqueue_media(); + wp_register_style( 'wc-global-presets', false ); // phpcs:ignore + wp_add_inline_style( 'wc-global-presets', wp_get_global_stylesheet( array( 'presets' ) ) ); + wp_enqueue_style( 'wc-global-presets' ); + } + + /** + * Replaces the default posts menu item with the new posts dashboard. + */ + public function woocommerce_add_new_products_dashboard() { + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + if ( ! $gutenberg_experiments ) { + return; + } + $ptype_obj = get_post_type_object( 'product' ); + add_submenu_page( + 'woocommerce', + $ptype_obj->labels->name, + esc_html__( 'All Products', 'woocommerce' ), + 'manage_woocommerce', + 'woocommerce-products-dashboard', + array( $this, 'woocommerce_products_dashboard' ), + 1 + ); + } + + /** + * Renders the new posts dashboard page. + */ + public function woocommerce_products_dashboard() { + $suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min'; + $version = Constants::get_constant( 'WC_VERSION' ); + if ( function_exists( 'gutenberg_url' ) ) { + // phpcs:disable WordPress.WP.EnqueuedResourceParameters.MissingVersion + wp_register_style( + 'wp-gutenberg-posts-dashboard', + gutenberg_url( 'build/edit-site/posts.css', __FILE__ ), + array( 'wp-components' ), + ); + // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion + wp_enqueue_style( 'wp-gutenberg-posts-dashboard' ); + } + WCAdminAssets::get_instance(); + wp_enqueue_script( 'wc-admin-product-editor', WC()->plugin_url() . '/assets/js/admin/product-editor' . $suffix . '.js', array( 'wc-product-editor' ), $version, false ); + wp_add_inline_script( 'wp-edit-site', 'window.wc.productEditor.initializeProductsDashboard( "woocommerce-products-dashboard" );', 'after' ); + wp_enqueue_script( 'wp-edit-site' ); + + echo '
'; + } +} diff --git a/plugins/woocommerce/src/Admin/Features/ShippingPartnerSuggestions/ShippingPartnerSuggestions.php b/plugins/woocommerce/src/Admin/Features/ShippingPartnerSuggestions/ShippingPartnerSuggestions.php index 19c2893252e..d309feddb17 100644 --- a/plugins/woocommerce/src/Admin/Features/ShippingPartnerSuggestions/ShippingPartnerSuggestions.php +++ b/plugins/woocommerce/src/Admin/Features/ShippingPartnerSuggestions/ShippingPartnerSuggestions.php @@ -20,14 +20,14 @@ class ShippingPartnerSuggestions extends RemoteSpecsEngine { $locale = get_user_locale(); $specs = is_array( $specs ) ? $specs : self::get_specs(); - $results = EvaluateSuggestion::evaluate_specs( $specs ); + $results = EvaluateSuggestion::evaluate_specs( $specs, array( 'source' => 'wc-shipping-partner-suggestions' ) ); $specs_to_return = $results['suggestions']; $specs_to_save = null; if ( empty( $specs_to_return ) ) { // When suggestions is empty, replace it with defaults and save for 3 hours. $specs_to_save = DefaultShippingPartners::get_all(); - $specs_to_return = EvaluateSuggestion::evaluate_specs( $specs_to_save )['suggestions']; + $specs_to_return = EvaluateSuggestion::evaluate_specs( $specs_to_save, array( 'source' => 'wc-shipping-partner-suggestions' ) )['suggestions']; } elseif ( count( $results['errors'] ) > 0 ) { // When suggestions is not empty but has errors, save it for 3 hours. $specs_to_save = $specs; diff --git a/plugins/woocommerce/src/Admin/Notes/Note.php b/plugins/woocommerce/src/Admin/Notes/Note.php index 8191b6aede7..de8cde59dc7 100644 --- a/plugins/woocommerce/src/Admin/Notes/Note.php +++ b/plugins/woocommerce/src/Admin/Notes/Note.php @@ -676,7 +676,7 @@ class Note extends \WC_Data { * * @param string $note_action_name Name of action to add a nonce to. * @param string $nonce_action The nonce action. - * @param string $nonce_name The nonce Name. This is used as the paramater name in the resulting URL for the action. + * @param string $nonce_name The nonce Name. This is used as the parameter name in the resulting URL for the action. * @return void * @throws \Exception If note name cannot be found. */ diff --git a/plugins/woocommerce/src/Admin/PageController.php b/plugins/woocommerce/src/Admin/PageController.php index c44525cef5c..0d8738d1ba5 100644 --- a/plugins/woocommerce/src/Admin/PageController.php +++ b/plugins/woocommerce/src/Admin/PageController.php @@ -220,7 +220,7 @@ class PageController { */ public function get_current_page() { // If 'current_screen' hasn't fired yet, the current page calculation - // will fail which causes `false` to be returned for all subsquent calls. + // will fail which causes `false` to be returned for all subsequent calls. if ( ! did_action( 'current_screen' ) ) { _doing_it_wrong( __FUNCTION__, esc_html__( 'Current page retrieval should be called on or after the `current_screen` hook.', 'woocommerce' ), '0.16.0' ); } @@ -394,7 +394,7 @@ class PageController { } /** - * Returns true if we are on a page registed with this controller. + * Returns true if we are on a page registered with this controller. * * @return boolean */ @@ -530,7 +530,7 @@ class PageController { */ public function remove_app_entry_page_menu_item() { global $submenu; - // User does not have capabilites to see the submenu. + // User does not have capabilities to see the submenu. if ( ! current_user_can( 'manage_woocommerce' ) || empty( $submenu['woocommerce'] ) ) { return; } @@ -574,6 +574,6 @@ class PageController { * TODO: See usage in `admin.php`. This needs refactored and implemented properly in core. */ public static function is_embed_page() { - return wc_admin_is_connected_page() || ( ! self::is_admin_page() && class_exists( 'Automattic\WooCommerce\Admin\Features\Navigation\Screen' ) && Screen::is_woocommerce_page() ); + return wc_admin_is_connected_page(); } } diff --git a/plugins/woocommerce/src/Admin/PluginsHelper.php b/plugins/woocommerce/src/Admin/PluginsHelper.php index 5d75c51dcf1..efcf9152f9e 100644 --- a/plugins/woocommerce/src/Admin/PluginsHelper.php +++ b/plugins/woocommerce/src/Admin/PluginsHelper.php @@ -14,6 +14,7 @@ use Automatic_Upgrader_Skin; use Automattic\WooCommerce\Admin\PluginsInstallLoggers\AsyncPluginsInstallLogger; use Automattic\WooCommerce\Admin\PluginsInstallLoggers\PluginsInstallLogger; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; +use Automattic\WooCommerce\Utilities\PluginUtil; use Plugin_Upgrader; use WC_Helper; use WC_Helper_Updater; @@ -43,6 +44,11 @@ class PluginsHelper { */ const WOO_SUBSCRIPTION_PAGE_URL = 'https://woocommerce.com/my-account/my-subscriptions/'; + /** + * The URL for the WooCommerce.com add payment method page. + */ + const WOO_ADD_PAYMENT_METHOD_URL = 'https://woocommerce.com/my-account/add-payment-method/'; + /** * Meta key for dismissing expired subscription notices. */ @@ -75,7 +81,7 @@ class PluginsHelper { * * @param string $slug Plugin slug to get path for. * - * @return string|false + * @return string|false The plugin path or false if the plugin is not installed. */ public static function get_plugin_path_from_slug( $slug ) { $plugins = get_plugins(); @@ -132,16 +138,25 @@ class PluginsHelper { /** * Get an array of active plugin slugs. * - * @return array + * The list will include both network active and site active plugins. + * + * @return array The list of active plugin slugs. */ public static function get_active_plugin_slugs() { - return array_map( - function ( $plugin_path ) { - $path_parts = explode( '/', $plugin_path ); + return array_unique( + array_map( + function ( $absolute_path ) { + // Make the path relative to the plugins directory. + $plugin_path = str_replace( WP_PLUGIN_DIR . '/', '', $absolute_path ); - return $path_parts[0]; - }, - get_option( 'active_plugins', array() ) + // Split the path to get the plugin slug (aka the directory name). + $path_parts = explode( '/', $plugin_path ); + + return $path_parts[0]; + }, + // Use this method as it is the most bulletproof way to get the active plugins. + wc_get_container()->get( PluginUtil::class )->get_all_active_valid_plugins() + ) ); } @@ -168,7 +183,7 @@ class PluginsHelper { public static function is_plugin_active( $plugin ) { $plugin_path = self::get_plugin_path_from_slug( $plugin ); - return $plugin_path ? in_array( $plugin_path, get_option( 'active_plugins', array() ), true ) : false; + return $plugin_path && \is_plugin_active( $plugin_path ); } /** @@ -350,7 +365,7 @@ class PluginsHelper { } /** - * Callback regsitered by OnboardingPlugins::install_and_activate_async. + * Callback registered by OnboardingPlugins::install_and_activate_async. * * It is used to call install_plugins and activate_plugins with a custom logger. * @@ -588,9 +603,11 @@ class PluginsHelper { $connect_page_url = add_query_arg( array( - 'page' => 'wc-admin', - 'tab' => 'my-subscriptions', - 'path' => rawurlencode( '/extensions' ), + 'page' => 'wc-admin', + 'tab' => 'my-subscriptions', + 'path' => rawurlencode( '/extensions' ), + 'utm_source' => 'pu', + 'utm_campaign' => 'pu_setting_screen_connect', ), admin_url( 'admin.php' ) ); @@ -706,7 +723,7 @@ class PluginsHelper { } /** - * Construct the subscritpion notice data based on user subscriptions data. + * Construct the subscription notice data based on user subscriptions data. * * @param array $all_subs all subscription data. * @param array $subs_to_show filtered subscriptions as condition. @@ -717,10 +734,19 @@ class PluginsHelper { */ public static function get_subscriptions_notice_data( array $all_subs, array $subs_to_show, int $total, array $messages, string $type ) { if ( 1 < $total ) { + $hyperlink_url = add_query_arg( + array( + 'utm_source' => 'pu', + 'utm_campaign' => 'expired' === $type ? 'pu_settings_screen_renew' : 'pu_settings_screen_enable_autorenew', + + ), + self::WOO_SUBSCRIPTION_PAGE_URL + ); + $parsed_message = sprintf( $messages['different_subscriptions'], esc_attr( $total ), - esc_url( self::WOO_SUBSCRIPTION_PAGE_URL ), + esc_url( $hyperlink_url ), esc_attr( $total ), ); @@ -752,8 +778,11 @@ class PluginsHelper { $expiry_date = date_i18n( 'F jS', $subscription['expires'] ); $hyperlink_url = add_query_arg( array( - 'product_id' => $product_id, - 'type' => $type, + 'product_id' => $product_id, + 'type' => $type, + 'utm_source' => 'pu', + 'utm_campaign' => 'expired' === $type ? 'pu_settings_screen_renew' : 'pu_settings_screen_enable_autorenew', + ), self::WOO_SUBSCRIPTION_PAGE_URL ); @@ -810,6 +839,7 @@ class PluginsHelper { $subscriptions, function ( $sub ) { return ( ! empty( $sub['local']['installed'] ) && ! empty( $sub['product_key'] ) ) + && ( $sub['active'] || empty( $sub['connections'] ) ) // Active on current site or not connected to any sites. && $sub['expiring'] && ! $sub['autorenew']; }, @@ -824,29 +854,7 @@ class PluginsHelper { // When payment method is missing on WooCommerce.com. $helper_notices = WC_Helper::get_notices(); if ( ! empty( $helper_notices['missing_payment_method_notice'] ) ) { - $description = $allowed_link - ? sprintf( - /* translators: %s: WooCommerce.com URL to add payment method */ - _n( - 'Your WooCommerce extension subscription is missing a payment method for renewal. Add a payment method to ensure you continue receiving updates and streamlined support.', - 'Your WooCommerce extension subscriptions are missing a payment method for renewal. Add a payment method to ensure you continue receiving updates and streamlined support.', - $total_expiring_subscriptions, - 'woocommerce' - ), - 'https://woocommerce.com/my-account/add-payment-method/' - ) - : _n( - 'Your WooCommerce extension subscription is missing a payment method for renewal. Add a payment method to ensure you continue receiving updates and streamlined support.', - 'Your WooCommerce extension subscriptions are missing a payment method for renewal. Add a payment method to ensure you continue receiving updates and streamlined support.', - $total_expiring_subscriptions, - 'woocommerce' - ); - - return array( - 'description' => $description, - 'button_text' => __( 'Add payment method', 'woocommerce' ), - 'button_link' => 'https://woocommerce.com/my-account/add-payment-method/', - ); + return self::get_missing_payment_method_notice( $allowed_link, $total_expiring_subscriptions ); } // Payment method is available but there are expiring subscriptions. @@ -865,14 +873,20 @@ class PluginsHelper { 'expiring', ); - $button_link = self::WOO_SUBSCRIPTION_PAGE_URL; + $button_link = add_query_arg( + array( + 'utm_source' => 'pu', + 'utm_campaign' => 'pu_in_apps_screen_enable_autorenew', + ), + self::WOO_SUBSCRIPTION_PAGE_URL + ); if ( in_array( $notice_data['type'], array( 'single_manage', 'multiple_manage' ), true ) ) { $button_link = add_query_arg( array( 'product_id' => $notice_data['product_id'], 'type' => 'expiring', ), - self::WOO_SUBSCRIPTION_PAGE_URL + $button_link ); } @@ -903,6 +917,7 @@ class PluginsHelper { $subscriptions, function ( $sub ) { return ( ! empty( $sub['local']['installed'] ) && ! empty( $sub['product_key'] ) ) + && ( $sub['active'] || empty( $sub['connections'] ) ) // Active on current site or not connected to any sites. && $sub['expired'] && ! $sub['lifetime']; }, @@ -923,21 +938,28 @@ class PluginsHelper { /* translators: 1) product name 3) URL to My Subscriptions page 4) Renew product price string */ 'single_manage' => __( 'Your subscription for %1$s expired. %4$s to continue receiving updates and streamlined support.', 'woocommerce' ), /* translators: 1) product name 3) URL to My Subscriptions page 4) Renew product price string */ - 'multiple_manage' => __( 'One of your subscriptions for %1$s has expired. %4$s. to continue receiving updates and streamlined support.', 'woocommerce' ), + 'multiple_manage' => __( 'One of your subscriptions for %1$s has expired. %4$s to continue receiving updates and streamlined support.', 'woocommerce' ), /* translators: 1) total expired subscriptions 2) URL to My Subscriptions page */ 'different_subscriptions' => __( 'You have %1$s Woo extension subscriptions that expired. Renew to continue receiving updates and streamlined support.', 'woocommerce' ), ), 'expired', ); - $button_link = self::WOO_SUBSCRIPTION_PAGE_URL; + $button_link = add_query_arg( + array( + 'utm_source' => 'pu', + 'utm_campaign' => $allowed_link ? 'pu_settings_screen_renew' : 'pu_in_apps_screen_renew', + ), + self::WOO_SUBSCRIPTION_PAGE_URL + ); + if ( in_array( $notice_data['type'], array( 'single_manage', 'multiple_manage' ), true ) ) { $button_link = add_query_arg( array( 'product_id' => $notice_data['product_id'], 'type' => 'expiring', ), - self::WOO_SUBSCRIPTION_PAGE_URL + $button_link ); } @@ -973,4 +995,45 @@ class PluginsHelper { return true; } + + /** + * Get the notice data for missing payment method. + * + * @param bool $allowed_link whether should show link on the notice or not. + * @param int $total_expiring_subscriptions total expiring subscriptions. + * + * @return array the notices data. + */ + public static function get_missing_payment_method_notice( $allowed_link = true, $total_expiring_subscriptions = 1 ) { + $add_payment_method_link = add_query_arg( + array( + 'utm_source' => 'pu', + 'utm_campaign' => $allowed_link ? 'pu_settings_screen_add_payment_method' : 'pu_in_apps_screen_add_payment_method', + ), + self::WOO_ADD_PAYMENT_METHOD_URL + ); + $description = $allowed_link + ? sprintf( + /* translators: %s: WooCommerce.com URL to add payment method */ + _n( + 'Your WooCommerce extension subscription is missing a payment method for renewal. Add a payment method to ensure you continue receiving updates and streamlined support.', + 'Your WooCommerce extension subscriptions are missing a payment method for renewal. Add a payment method to ensure you continue receiving updates and streamlined support.', + $total_expiring_subscriptions, + 'woocommerce' + ), + $add_payment_method_link + ) + : _n( + 'Your WooCommerce extension subscription is missing a payment method for renewal. Add a payment method to ensure you continue receiving updates and streamlined support.', + 'Your WooCommerce extension subscriptions are missing a payment method for renewal. Add a payment method to ensure you continue receiving updates and streamlined support.', + $total_expiring_subscriptions, + 'woocommerce' + ); + + return array( + 'description' => $description, + 'button_text' => __( 'Add payment method', 'woocommerce' ), + 'button_link' => $add_payment_method_link, + ); + } } diff --git a/plugins/woocommerce/src/Admin/PluginsInstallLoggers/AsyncPluginsInstallLogger.php b/plugins/woocommerce/src/Admin/PluginsInstallLoggers/AsyncPluginsInstallLogger.php index 1e884328851..87faf90aa7a 100644 --- a/plugins/woocommerce/src/Admin/PluginsInstallLoggers/AsyncPluginsInstallLogger.php +++ b/plugins/woocommerce/src/Admin/PluginsInstallLoggers/AsyncPluginsInstallLogger.php @@ -32,7 +32,7 @@ class AsyncPluginsInstallLogger implements PluginsInstallLogger { 'no' ); - // Set status as failed in case we run out of exectuion time. + // Set status as failed in case we run out of execution time. register_shutdown_function( function () { $error = error_get_last(); @@ -57,7 +57,7 @@ class AsyncPluginsInstallLogger implements PluginsInstallLogger { } /** - * Retreive the option. + * Retrieve the option. * * @return false|mixed|void */ diff --git a/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php b/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php index 8555ff9ba18..595a7788053 100644 --- a/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php +++ b/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php @@ -7,11 +7,14 @@ namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications; defined( 'ABSPATH' ) || exit; +use Automattic\WooCommerce\Admin\Features\Features; +use Automattic\WooCommerce\Admin\Notes\Notes; use Automattic\WooCommerce\Admin\PluginsProvider\PluginsProvider; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile; use Automattic\WooCommerce\Admin\Notes\Note; use Automattic\WooCommerce\Admin\RemoteSpecs\RemoteSpecsEngine; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\StoredStateSetupForProducts; +use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; /** * Remote Inbox Notifications engine. @@ -19,11 +22,15 @@ use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\StoredStateSetupForP * specs that are able to be triggered. */ class RemoteInboxNotificationsEngine extends RemoteSpecsEngine { + use AccessiblePrivateMethods; + const STORED_STATE_OPTION_NAME = 'wc_remote_inbox_notifications_stored_state'; const WCA_UPDATED_OPTION_NAME = 'wc_remote_inbox_notifications_wca_updated'; /** * Initialize the engine. + * phpcs:disable WooCommerce.Functions.InternalInjectionMethod.MissingFinal + * phpcs:disable WooCommerce.Functions.InternalInjectionMethod.MissingInternalTag */ public static function init() { // Init things that need to happen before admin_init. @@ -42,10 +49,13 @@ class RemoteInboxNotificationsEngine extends RemoteSpecsEngine { // Hook into WCA updated. This is hooked up here rather than in // on_admin_init because that runs too late to hook into the action. - add_action( 'woocommerce_run_on_woocommerce_admin_updated', array( __CLASS__, 'run_on_woocommerce_admin_updated' ) ); + add_action( + 'woocommerce_run_on_woocommerce_admin_updated', + array( __CLASS__, 'run_on_woocommerce_admin_updated' ) + ); add_action( 'woocommerce_updated', - function() { + function () { $next_hook = WC()->queue()->get_next( 'woocommerce_run_on_woocommerce_admin_updated', array(), @@ -63,6 +73,11 @@ class RemoteInboxNotificationsEngine extends RemoteSpecsEngine { ); add_filter( 'woocommerce_get_note_from_db', array( __CLASS__, 'get_note_from_db' ), 10, 1 ); + self::add_filter( 'woocommerce_debug_tools', array( __CLASS__, 'add_debug_tools' ) ); + self::add_action( + 'wp_ajax_woocommerce_json_inbox_notifications_search', + array( __CLASS__, 'ajax_action_inbox_notification_search' ) + ); } /** @@ -155,7 +170,7 @@ class RemoteInboxNotificationsEngine extends RemoteSpecsEngine { public static function get_stored_state() { $stored_state = get_option( self::STORED_STATE_OPTION_NAME ); - if ( $stored_state === false ) { + if ( false === $stored_state ) { $stored_state = new \stdClass(); $stored_state = StoredStateSetupForProducts::init_stored_state( @@ -199,6 +214,7 @@ class RemoteInboxNotificationsEngine extends RemoteSpecsEngine { * Get the note. This is used to display localized note. * * @param Note $note_from_db The note object created from db. + * * @return Note The note. */ public static function get_note_from_db( $note_from_db ) { @@ -211,7 +227,7 @@ class RemoteInboxNotificationsEngine extends RemoteSpecsEngine { continue; } $locale = SpecRunner::get_locale( $spec->locales, true ); - if ( $locale === null ) { + if ( null === $locale ) { // No locale found, so don't update the note. break; } @@ -233,4 +249,94 @@ class RemoteInboxNotificationsEngine extends RemoteSpecsEngine { return $note_from_db; } + + /** + * Add the debug tools to the WooCommerce debug tools (WooCommerce > Status > Tools). + * + * @param array $tools a list of tools. + * + * @return mixed + */ + private static function add_debug_tools( $tools ) { + // Check if the feature flag is disabled. + if ( ! Features::is_enabled( 'remote-inbox-notifications' ) ) { + return false; + } + + // Check if the site has opted out of marketplace suggestions. + if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) !== 'yes' ) { + return false; + } + + $tools['refresh_remote_inbox_notifications'] = array( + 'name' => __( 'Refresh Remote Inbox Notifications', 'woocommerce' ), + 'button' => __( 'Refresh', 'woocommerce' ), + 'desc' => __( 'This will refresh the remote inbox notifications', 'woocommerce' ), + 'callback' => function () { + RemoteInboxNotificationsDataSourcePoller::get_instance()->read_specs_from_data_sources(); + RemoteInboxNotificationsEngine::run(); + + return __( 'Remote inbox notifications have been refreshed', 'woocommerce' ); + }, + ); + + $tools['delete_inbox_notification'] = array( + 'name' => __( 'Delete an Inbox Notification', 'woocommerce' ), + 'button' => __( 'Delete', 'woocommerce' ), + 'desc' => __( 'This will delete an inbox notification by slug', 'woocommerce' ), + 'selector' => array( + 'description' => __( 'Select an inbox notification to delete:', 'woocommerce' ), + 'class' => 'wc-product-search', + 'search_action' => 'woocommerce_json_inbox_notifications_search', + 'name' => 'delete_inbox_notification_note_id', + 'placeholder' => esc_attr__( 'Search for an inbox notification…', 'woocommerce' ), + ), + 'callback' => function () { + check_ajax_referer( 'debug_action', '_wpnonce' ); + + if ( ! isset( $_GET['delete_inbox_notification_note_id'] ) ) { + return __( 'No inbox notification selected', 'woocommerce' ); + } + $note_id = wc_clean( sanitize_text_field( wp_unslash( $_GET['delete_inbox_notification_note_id'] ) ) ); + $note = Notes::get_note( $note_id ); + + if ( ! $note ) { + return __( 'Inbox notification not found', 'woocommerce' ); + } + + $note->delete( true ); + return __( 'Inbox notification has been deleted', 'woocommerce' ); + }, + ); + + return $tools; + } + + /** + * Add ajax action for remote inbox notification search. + * + * @return void + */ + private static function ajax_action_inbox_notification_search() { + global $wpdb; + + check_ajax_referer( 'search-products', 'security' ); + + if ( ! isset( $_GET['term'] ) ) { + wp_send_json( array() ); + } + + $search = wc_clean( sanitize_text_field( wp_unslash( $_GET['term'] ) ) ); + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT note_id, name FROM {$wpdb->prefix}wc_admin_notes WHERE name LIKE %s", + '%' . $wpdb->esc_like( $search ) . '%' + ) + ); + $rows = array(); + foreach ( $results as $result ) { + $rows[ $result->note_id ] = $result->name; + } + wp_send_json( $rows ); + } } diff --git a/plugins/woocommerce/src/Admin/RemoteSpecs/DataSourcePoller.php b/plugins/woocommerce/src/Admin/RemoteSpecs/DataSourcePoller.php index d341d3dc073..d0812fd5ffa 100644 --- a/plugins/woocommerce/src/Admin/RemoteSpecs/DataSourcePoller.php +++ b/plugins/woocommerce/src/Admin/RemoteSpecs/DataSourcePoller.php @@ -106,9 +106,9 @@ abstract class DataSourcePoller { public function get_specs_from_data_sources() { $locale = get_user_locale(); $specs_group = get_transient( $this->args['transient_name'] ) ?? array(); - $specs = isset( $specs_group[ $locale ] ) ? $specs_group[ $locale ] : array(); + $specs = isset( $specs_group[ $locale ] ) ? $specs_group[ $locale ] : null; - if ( ! is_array( $specs ) || empty( $specs ) ) { + if ( ! is_array( $specs ) ) { $this->read_specs_from_data_sources(); $specs_group = get_transient( $this->args['transient_name'] ); $specs = isset( $specs_group[ $locale ] ) ? $specs_group[ $locale ] : array(); @@ -126,6 +126,29 @@ abstract class DataSourcePoller { return false !== $specs ? $specs : array(); } + /** + * Gets specs from cache if it exists. + * + * @return array list of specs. + */ + public function get_cached_specs() { + $locale = get_user_locale(); + $specs_group = get_transient( $this->args['transient_name'] ) ?? array(); + $specs = isset( $specs_group[ $locale ] ) ? $specs_group[ $locale ] : null; + + /** + * Filter specs. + * + * @param array $specs List of specs. + * @param string $this->id Spec identifier. + * + * @since 8.8.0 + */ + $specs = apply_filters( self::FILTER_NAME_SPECS, $specs, $this->id ); + + return false !== $specs ? $specs : array(); + } + /** * Reads the data sources for specs and persists those specs. * diff --git a/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/EvaluationLogger.php b/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/EvaluationLogger.php index e2cf71eee5d..89010ca6960 100644 --- a/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/EvaluationLogger.php +++ b/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/EvaluationLogger.php @@ -25,23 +25,23 @@ class EvaluationLogger { /** * Logger class to use. * - * @var WC_Logger_Interface|null + * @var \WC_Logger_Interface|null */ private $logger; /** * Logger source. * - * @var string logger source. + * @var string Logger source. */ private $source = ''; /** * EvaluationLogger constructor. * - * @param string $slug Slug of a spec that is being evaluated. - * @param null $source Logger source. - * @param \WC_Logger_Interface $logger Logger class to use. + * @param string $slug Slug/ID of a spec that is being evaluated. + * @param string|null $source Logger source. + * @param \WC_Logger_Interface|null $logger Logger class to use. Default to using the WC logger. */ public function __construct( $slug, $source = null, \WC_Logger_Interface $logger = null ) { $this->slug = $slug; @@ -59,16 +59,13 @@ class EvaluationLogger { /** * Add evaluation result of a rule. * - * @param string $rule_type name of the rule being tested. - * @param boolean $result result of a given rule. + * @param string $rule_type Name of the rule being tested. + * @param boolean $result Result of a given rule. */ public function add_result( $rule_type, $result ) { - array_push( - $this->results, - array( - 'rule' => $rule_type, - 'result' => $result ? 'passed' : 'failed', - ) + $this->results[] = array( + 'rule' => $rule_type, + 'result' => $result ? 'passed' : 'failed', ); } @@ -76,7 +73,16 @@ class EvaluationLogger { * Log the results. */ public function log() { - if ( false === defined( 'WC_ADMIN_DEBUG_RULE_EVALUATOR' ) || true !== constant( 'WC_ADMIN_DEBUG_RULE_EVALUATOR' ) ) { + $should_log = defined( 'WC_ADMIN_DEBUG_RULE_EVALUATOR' ) && true === constant( 'WC_ADMIN_DEBUG_RULE_EVALUATOR' ); + + /** + * Filter to determine if the rule evaluator should log the results. + * + * @since 9.2.0 + * + * @param bool $should_log Whether the rule evaluator should log the results. + */ + if ( ! apply_filters( 'woocommerce_admin_remote_specs_evaluator_should_log', $should_log ) ) { return; } diff --git a/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/RuleEvaluator.php b/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/RuleEvaluator.php index a4f3977d9b8..0604305c368 100644 --- a/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/RuleEvaluator.php +++ b/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/RuleEvaluator.php @@ -36,9 +36,9 @@ class RuleEvaluator { * Evaluate the given rules as an AND operation - return false early if a * rule evaluates to false. * - * @param array|object $rules The rule or rules being processed. + * @param array|object $rules The rule or rules being processed. * @param object|null $stored_state Stored state. - * @param array $logger_args Arguments for the event logger. `slug` is required. + * @param array $logger_args Arguments for the rule evaluator logger. `slug` is required. * * @throws \InvalidArgumentException Thrown when $logger_args is missing slug. * @@ -65,13 +65,16 @@ class RuleEvaluator { throw new \InvalidArgumentException( 'Missing required field: slug in $logger_args.' ); } - array_key_exists( 'source', $logger_args ) ? $source = $logger_args['source'] : $source = null; + $source = isset( $logger_args['source'] ) ? $logger_args['source'] : null; $evaluation_logger = new EvaluationLogger( $logger_args['slug'], $source ); } foreach ( $rules as $rule ) { if ( ! is_object( $rule ) ) { + $evaluation_logger && $evaluation_logger->add_result( 'rule not an object', false ); + $evaluation_logger && $evaluation_logger->log(); + return false; } diff --git a/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/Transformers/TransformerService.php b/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/Transformers/TransformerService.php index ec3fc8c9c5b..9519627f374 100644 --- a/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/Transformers/TransformerService.php +++ b/plugins/woocommerce/src/Admin/RemoteSpecs/RuleProcessors/Transformers/TransformerService.php @@ -39,7 +39,7 @@ class TransformerService { * @param bool $is_default_set flag on is default value set. * @param string $default_value default value. * - * @throws InvalidArgumentException Throws when one of the requried arguments is missing. + * @throws InvalidArgumentException Throws when one of the required arguments is missing. * @return mixed|null */ public static function apply( $target_value, array $transformer_configs, $is_default_set, $default_value ) { diff --git a/plugins/woocommerce/src/Admin/Schedulers/SchedulerTraits.php b/plugins/woocommerce/src/Admin/Schedulers/SchedulerTraits.php index 34ee0391ec6..e6ac4583f57 100644 --- a/plugins/woocommerce/src/Admin/Schedulers/SchedulerTraits.php +++ b/plugins/woocommerce/src/Admin/Schedulers/SchedulerTraits.php @@ -228,20 +228,7 @@ trait SchedulerTraits { ) ); - $next_job_schedule = null; - - if ( is_array( $blocking_jobs ) ) { - foreach ( $blocking_jobs as $blocking_job ) { - $next_job_schedule = self::get_next_action_time( $blocking_job ); - - // Ensure that the next schedule is a DateTime (it can be null). - if ( is_a( $next_job_schedule, 'DateTime' ) ) { - return $blocking_job; - } - } - } - - return false; + return reset( $blocking_jobs ); } /** @@ -256,9 +243,15 @@ trait SchedulerTraits { // or schedule to run now if no blocking jobs exist. $blocking_job = static::get_next_blocking_job( $action_name ); if ( $blocking_job ) { - $after = new \DateTime(); + $next_action_time = self::get_next_action_time( $blocking_job ); + + // Some actions, like single actions, don't have a next action time. + if ( ! is_a( $next_action_time, 'DateTime' ) ) { + $next_action_time = new \DateTime(); + } + self::queue()->schedule_single( - self::get_next_action_time( $blocking_job )->getTimestamp() + 5, + $next_action_time->getTimestamp() + 5, $action_hook, $args, static::$group diff --git a/plugins/woocommerce/src/Admin/WCAdminHelper.php b/plugins/woocommerce/src/Admin/WCAdminHelper.php index d46ade4b723..9e234762eb8 100644 --- a/plugins/woocommerce/src/Admin/WCAdminHelper.php +++ b/plugins/woocommerce/src/Admin/WCAdminHelper.php @@ -150,12 +150,23 @@ class WCAdminHelper { } $normalized_path = self::get_normalized_url_path( $url ); + $params = array( + 'post_type' => 'product', + ); + + parse_str( wp_parse_url( $url, PHP_URL_QUERY ), $url_params ); + + foreach ( $params as $key => $param ) { + if ( isset( $url_params[ $key ] ) && $url_params[ $key ] === $param ) { + return true; + } + } + // WC store pages. $store_pages = array( 'shop' => wc_get_page_id( 'shop' ), 'cart' => wc_get_page_id( 'cart' ), 'checkout' => wc_get_page_id( 'checkout' ), - 'privacy' => wc_privacy_policy_page_id(), 'terms' => wc_terms_and_conditions_page_id(), 'coming_soon' => wc_get_page_id( 'coming_soon' ), ); @@ -186,6 +197,7 @@ class WCAdminHelper { } $permalink = get_permalink( $page_id ); + if ( ! $permalink ) { continue; } @@ -230,7 +242,16 @@ class WCAdminHelper { } } - return false; + /** + * Filter if a URL is a store page. + * + * @since 9.3.0 + * @param bool $is_store_page Whether or not the URL is a store page. + * @param string $url URL to check. + */ + $is_store_page = apply_filters( 'woocommerce_is_extension_store_page', false, $url ); + + return filter_var( $is_store_page, FILTER_VALIDATE_BOOL ); } /** diff --git a/plugins/woocommerce/src/Blocks/AIContent/PatternsDictionary.php b/plugins/woocommerce/src/Blocks/AIContent/PatternsDictionary.php new file mode 100644 index 00000000000..cb87c48b8ea --- /dev/null +++ b/plugins/woocommerce/src/Blocks/AIContent/PatternsDictionary.php @@ -0,0 +1,673 @@ + 'Banner', + 'slug' => 'woocommerce-blocks/banner', + 'images_total' => 1, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Up to 60% off', 'woocommerce' ), + 'ai_prompt' => __( 'A four words title advertising the sale', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'Holiday Sale', 'woocommerce' ), + 'ai_prompt' => __( 'A two words label with the sale name', 'woocommerce' ), + ], + [ + 'default' => __( 'Get your favorite vinyl at record-breaking prices.', 'woocommerce' ), + 'ai_prompt' => __( 'The main description of the sale with at least 65 characters', 'woocommerce' ), + ], + ], + 'buttons' => [ + [ + 'default' => __( 'Shop vinyl records', 'woocommerce' ), + 'ai_prompt' => __( 'A 3 words button text to go to the sale page', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Discount Banner', + 'slug' => 'woocommerce-blocks/discount-banner', + 'content' => [ + 'descriptions' => [ + [ + 'default' => __( 'Select products', 'woocommerce' ), + 'ai_prompt' => __( 'A two words description of the products on sale', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Discount Banner with Image', + 'slug' => 'woocommerce-blocks/discount-banner-with-image', + 'images_total' => 1, + 'images_format' => 'landscape', + 'content' => [ + 'descriptions' => [ + [ + 'default' => __( 'Select products', 'woocommerce' ), + 'ai_prompt' => __( 'A two words description of the products on sale', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Featured Category Focus', + 'slug' => 'woocommerce-blocks/featured-category-focus', + 'images_total' => 1, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Black and white high-quality prints', 'woocommerce' ), + 'ai_prompt' => __( 'The four words title of the featured category related to the following image description: [image.0]', 'woocommerce' ), + ], + ], + 'buttons' => [ + [ + 'default' => __( 'Shop prints', 'woocommerce' ), + 'ai_prompt' => __( 'A two words button text to go to the featured category', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Featured Category Triple', + 'slug' => 'woocommerce-blocks/featured-category-triple', + 'images_total' => 3, + 'images_format' => 'portrait', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Home decor', 'woocommerce' ), + 'ai_prompt' => __( 'A one-word graphic title that encapsulates the essence of the business, inspired by the following image description: [image.0] and the nature of the business. The title should reflect the key elements and characteristics of the business, as portrayed in the image', 'woocommerce' ), + ], + [ + 'default' => __( 'Retro photography', 'woocommerce' ), + 'ai_prompt' => __( 'A two-words graphic title that encapsulates the essence of the business, inspired by the following image description: [image.1] and the nature of the business. The title should reflect the key elements and characteristics of the business, as portrayed in the image', 'woocommerce' ), + ], + [ + 'default' => __( 'Handmade gifts', 'woocommerce' ), + 'ai_prompt' => __( 'A two-words graphic title that encapsulates the essence of the business, inspired by the following image description: [image.2] and the nature of the business. The title should reflect the key elements and characteristics of the business, as portrayed in the image', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Featured Products: Fresh & Tasty', + 'slug' => 'woocommerce-blocks/featured-products-fresh-and-tasty', + 'images_total' => 4, + 'images_format' => 'portrait', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Fresh & tasty goods', 'woocommerce' ), + 'ai_prompt' => __( 'The title of the featured products with at least 20 characters', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'Sweet Organic Lemons', 'woocommerce' ), + 'ai_prompt' => __( 'The three words description of the featured product related to the following image description: [image.0]', 'woocommerce' ), + ], + [ + 'default' => __( 'Fresh Organic Tomatoes', 'woocommerce' ), + 'ai_prompt' => __( 'The three words description of the featured product related to the following image description: [image.1]', 'woocommerce' ), + ], + [ + 'default' => __( 'Fresh Lettuce (Washed)', 'woocommerce' ), + 'ai_prompt' => __( 'The three words description of the featured product related to the following image description: [image.2]', 'woocommerce' ), + ], + [ + 'default' => __( 'Russet Organic Potatoes', 'woocommerce' ), + 'ai_prompt' => __( 'The three words description of the featured product related to the following image description: [image.3]', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Hero Product 3 Split', + 'slug' => 'woocommerce-blocks/hero-product-3-split', + 'images_total' => 1, + 'images_format' => 'portrait', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Timeless elegance', 'woocommerce' ), + 'ai_prompt' => __( 'Write a two words title for advertising the store', 'woocommerce' ), + ], + [ + 'default' => __( 'Durable glass', 'woocommerce' ), + 'ai_prompt' => __( 'Write a two words title for advertising the store', 'woocommerce' ), + ], + [ + 'default' => __( 'Versatile charm', 'woocommerce' ), + 'ai_prompt' => __( 'Write a two words title for advertising the store', 'woocommerce' ), + ], + [ + 'default' => __( 'New: Retro Glass Jug', 'woocommerce' ), + 'ai_prompt' => __( 'Write a title with less than 20 characters for advertising the store', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'Elevate your table with a 330ml Retro Glass Jug, blending classic design and durable hardened glass.', 'woocommerce' ), + 'ai_prompt' => __( 'Write a text with approximately 130 characters, to describe a product the business is selling', 'woocommerce' ), + ], + [ + 'default' => __( 'Crafted from resilient thick glass, this jug ensures lasting quality, making it perfect for everyday use with a touch of vintage charm.', 'woocommerce' ), + 'ai_prompt' => __( 'Write a text with approximately 130 characters, to describe a product the business is selling', 'woocommerce' ), + ], + [ + 'default' => __( "The Retro Glass Jug's classic silhouette effortlessly complements any setting, making it the ideal choice for serving beverages with style and flair.", 'woocommerce' ), + 'ai_prompt' => __( 'Write a long text, with at least 130 characters, to describe a product the business is selling', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Hero Product Chessboard', + 'slug' => 'woocommerce-blocks/hero-product-chessboard', + 'images_total' => 2, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Quality Materials', 'woocommerce' ), + 'ai_prompt' => __( 'A two words title describing the first displayed product feature', 'woocommerce' ), + ], + [ + 'default' => __( 'Unique design', 'woocommerce' ), + 'ai_prompt' => __( 'A two words title describing the second displayed product feature', 'woocommerce' ), + ], + [ + 'default' => __( 'Make your house feel like home', 'woocommerce' ), + 'ai_prompt' => __( 'A two words title describing the fourth displayed product feature', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'We use only the highest-quality materials in our products, ensuring that they look great and last for years to come.', 'woocommerce' ), + 'ai_prompt' => __( 'A description of the product feature with at least 115 characters', 'woocommerce' ), + ], + [ + 'default' => __( 'From bold prints to intricate details, our products are a perfect combination of style and function.', 'woocommerce' ), + 'ai_prompt' => __( 'A description of the product feature with at least 115 characters', 'woocommerce' ), + ], + [ + 'default' => __( 'Add a touch of charm and coziness this holiday season with a wide selection of hand-picked decorations — from minimalist vases to designer furniture.', 'woocommerce' ), + 'ai_prompt' => __( 'A description of the product feature with at least 115 characters', 'woocommerce' ), + ], + ], + 'buttons' => [ + [ + 'default' => __( 'Shop home decor', 'woocommerce' ), + 'ai_prompt' => __( 'A two words button text to go to the product page', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Hero Product Split', + 'slug' => 'woocommerce-blocks/hero-product-split', + 'images_total' => 1, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Keep dry with 50% off rain jackets', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the product the store is selling with at least 35 characters', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Just Arrived Full Hero', + 'slug' => 'woocommerce-blocks/just-arrived-full-hero', + 'images_total' => 1, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Sound like no other', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the displayed product collection with at least 10 characters', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'Experience your music like never before with our latest generation of hi-fidelity headphones.', 'woocommerce' ), + 'ai_prompt' => __( 'A description of the product collection with at least 35 characters', 'woocommerce' ), + ], + ], + 'buttons' => [ + [ + 'default' => __( 'Shop now', 'woocommerce' ), + 'ai_prompt' => __( 'A two words button text to go to the product collection page', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Product Collection Banner', + 'slug' => 'woocommerce-blocks/product-collection-banner', + 'images_total' => 1, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Brand New for the Holidays', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the displayed product collection with at least 25 characters related to the following image description: [image.0]', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'Check out our brand new collection of holiday products and find the right gift for anyone.', 'woocommerce' ), + 'ai_prompt' => __( 'A description of the product collection with at least 90 characters', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Product Collections Featured Collection', + 'slug' => 'woocommerce-blocks/product-collections-featured-collection', + 'content' => [ + 'titles' => [ + [ + 'default' => "This week's popular products", + 'ai_prompt' => __( 'An impact phrase that advertises the displayed product collection with at least 30 characters', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Product Collections Featured Collections', + 'slug' => 'woocommerce-blocks/product-collections-featured-collections', + 'images_total' => 4, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Tech gifts under $100', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the product collection with at least 20 characters related to the following image descriptions: [image.0], [image.1]', 'woocommerce' ), + ], + [ + 'default' => __( 'For the gamers', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the product collection with at least 15 characters related to the following image descriptions: [image.2], [image.3]', 'woocommerce' ), + ], + ], + 'buttons' => [ + [ + 'default' => __( 'Shop tech', 'woocommerce' ), + 'ai_prompt' => __( 'A two words button text to go to the product collection page', 'woocommerce' ), + ], + [ + 'default' => __( 'Shop games', 'woocommerce' ), + 'ai_prompt' => __( 'A two words button text to go to the product collection page', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Product Collections Newest Arrivals', + 'slug' => 'woocommerce-blocks/product-collections-newest-arrivals', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Our newest arrivals', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the displayed product collection with at least 20 characters', 'woocommerce' ), + ], + ], + 'buttons' => [ + [ + 'default' => __( 'More new products', 'woocommerce' ), + 'ai_prompt' => __( 'The button text to go to the product collection page with at least 15 characters', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Product Collection 4 Columns', + 'slug' => 'woocommerce-blocks/product-collection-4-columns', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Staff picks', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the displayed product collection with at least 20 characters', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Product Collection 5 Columns', + 'slug' => 'woocommerce-blocks/product-collection-5-columns', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Our latest and greatest', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase with that advertises the product collection with at least 20 characters', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Product Gallery', + 'slug' => 'woocommerce-blocks/product-query-product-gallery', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Bestsellers', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the featured products with at least 10 characters', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Featured Products 2 Columns', + 'slug' => 'woocommerce-blocks/featured-products-2-cols', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Fan favorites', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the featured products with at least 10 characters', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'Get ready to start the season right. All the fan favorites in one place at the best price.', 'woocommerce' ), + 'ai_prompt' => __( 'A description of the featured products with at least 90 characters', 'woocommerce' ), + ], + ], + 'buttons' => [ + [ + 'default' => __( 'Shop All', 'woocommerce' ), + 'ai_prompt' => __( 'A two words button text to go to the featured products page', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Product Hero 2 Column 2 Row', + 'slug' => 'woocommerce-blocks/product-hero-2-col-2-row', + 'images_total' => 2, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'The Eden Jacket', 'woocommerce' ), + 'ai_prompt' => __( 'A three words title that advertises a product related to the following image description: [image.0]', 'woocommerce' ), + ], + [ + 'default' => __( '100% Woolen', 'woocommerce' ), + 'ai_prompt' => __( 'A two words title that advertises a product feature', 'woocommerce' ), + ], + [ + 'default' => __( 'Fits your wardrobe', 'woocommerce' ), + 'ai_prompt' => __( 'A three words title that advertises a product feature', 'woocommerce' ), + ], + [ + 'default' => __( 'Versatile', 'woocommerce' ), + 'ai_prompt' => __( 'An one word title that advertises a product feature', 'woocommerce' ), + ], + [ + 'default' => __( 'Normal Fit', 'woocommerce' ), + 'ai_prompt' => __( 'A two words title that advertises a product feature', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'Perfect for any look featuring a mid-rise, relax fitting silhouette.', 'woocommerce' ), + 'ai_prompt' => __( 'The description of a product with at least 65 characters related to the following image: [image.0]', 'woocommerce' ), + ], + [ + 'default' => __( 'Reflect your fashionable style.', 'woocommerce' ), + 'ai_prompt' => __( 'The description of a product feature with at least 30 characters', 'woocommerce' ), + ], + [ + 'default' => __( 'Half tuck into your pants or layer over.', 'woocommerce' ), + 'ai_prompt' => __( 'The description of a product feature with at least 30 characters', 'woocommerce' ), + ], + [ + 'default' => __( 'Button-down front for any type of mood or look.', 'woocommerce' ), + 'ai_prompt' => __( 'The description of a product feature with at least 30 characters', 'woocommerce' ), + ], + [ + 'default' => __( '42% Cupro 34% Linen 24% Viscose', 'woocommerce' ), + 'ai_prompt' => __( 'The description of a product feature with at least 30 characters', 'woocommerce' ), + ], + ], + 'buttons' => [ + [ + 'default' => __( 'View product', 'woocommerce' ), + 'ai_prompt' => __( 'A two words button text to go to the product page', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Shop by Price', + 'slug' => 'woocommerce-blocks/shop-by-price', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Outdoor Furniture & Accessories', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the first product collection with at least 30 characters', 'woocommerce' ), + ], + [ + 'default' => __( 'Summer Dinning', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the second product collection with at least 20 characters', 'woocommerce' ), + ], + [ + 'default' => "Women's Styles", + 'ai_prompt' => __( 'An impact phrase that advertises the third product collection with at least 20 characters', 'woocommerce' ), + ], + [ + 'default' => "Kids' Styles", + 'ai_prompt' => __( 'An impact phrase that advertises the fourth product collection with at least 20 characters', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Small Discount Banner with Image', + 'slug' => 'woocommerce-blocks/small-discount-banner-with-image', + 'images_total' => 1, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Chairs', 'woocommerce' ), + 'ai_prompt' => __( 'A single word that advertises the product and is related to the following image description: [image.0]', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Social: Follow us on social media', + 'slug' => 'woocommerce-blocks/social-follow-us-in-social-media', + 'images_total' => 4, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Stay in the loop', 'woocommerce' ), + 'ai_prompt' => __( 'A phrase that advertises the social media accounts of the store with at least 25 characters', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Alternating Image and Text', + 'slug' => 'woocommerce-blocks/alt-image-and-text', + 'images_total' => 2, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Our products', 'woocommerce' ), + 'ai_prompt' => __( 'A two words impact phrase that advertises the products', 'woocommerce' ), + ], + [ + 'default' => __( 'Sustainable blends, stylish accessories', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the products with at least 40 characters and related to the following image description: [image.0]', 'woocommerce' ), + ], + [ + 'default' => __( 'About us', 'woocommerce' ), + 'ai_prompt' => __( 'A two words impact phrase that advertises the brand', 'woocommerce' ), + ], + [ + 'default' => __( 'Committed to a greener lifestyle', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the brand with at least 50 characters related to the following image description: [image.1]', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'Indulge in the finest organic coffee beans, teas, and hand-picked accessories, all locally sourced and sustainable for a mindful lifestyle.', 'woocommerce' ), + 'ai_prompt' => __( 'A description of the products with at least 180 characters', 'woocommerce' ), + ], + [ + 'default' => "Our passion is crafting mindful moments with locally sourced, organic, and sustainable products. We're more than a store; we're your path to a community-driven, eco-friendly lifestyle that embraces premium quality.", + 'ai_prompt' => __( 'A description of the products with at least 180 characters', 'woocommerce' ), + ], + [ + 'default' => __( 'Locally sourced ingredients', 'woocommerce' ), + 'ai_prompt' => __( 'A three word description of the products', 'woocommerce' ), + ], + [ + 'default' => __( 'Premium organic blends', 'woocommerce' ), + 'ai_prompt' => __( 'A three word description of the products', 'woocommerce' ), + ], + [ + 'default' => __( 'Hand-picked accessories', 'woocommerce' ), + 'ai_prompt' => __( 'A three word description of the products', 'woocommerce' ), + ], + [ + 'default' => __( 'Sustainable business practices', 'woocommerce' ), + 'ai_prompt' => __( 'A three word description of the products', 'woocommerce' ), + ], + ], + 'buttons' => [ + [ + 'default' => __( 'Meet us', 'woocommerce' ), + 'ai_prompt' => __( 'A two words button text to go to the product page', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Testimonials 3 Columns', + 'slug' => 'woocommerce-blocks/testimonials-3-columns', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Eclectic finds, ethical delights', 'woocommerce' ), + 'ai_prompt' => __( 'Write a short title advertising a testimonial from a customer', 'woocommerce' ), + ], + [ + 'default' => __( 'Sip, Shop, Savor', 'woocommerce' ), + 'ai_prompt' => __( 'Write a short title advertising a testimonial from a customer', 'woocommerce' ), + ], + [ + 'default' => __( 'LOCAL LOVE', 'woocommerce' ), + 'ai_prompt' => __( 'Write a short title advertising a testimonial from a customer', 'woocommerce' ), + ], + [ + 'default' => __( 'What our customers say', 'woocommerce' ), + 'ai_prompt' => __( 'Write just 4 words to advertise testimonials from customers', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'Transformed my daily routine with unique, eco-friendly treasures. Exceptional quality and service. Proud to support a store that aligns with my values.', 'woocommerce' ), + 'ai_prompt' => __( 'Write the testimonial from a customer with approximately 150 characters', 'woocommerce' ), + ], + [ + 'default' => __( 'The organic coffee beans are a revelation. Each sip feels like a journey. Beautifully crafted accessories add a touch of elegance to my home.', 'woocommerce' ), + 'ai_prompt' => __( 'Write the testimonial from a customer with approximately 150 characters', 'woocommerce' ), + ], + [ + 'default' => __( 'From sustainably sourced teas to chic vases, this store is a treasure trove. Love knowing my purchases contribute to a greener planet.', 'woocommerce' ), + 'ai_prompt' => __( 'Write the testimonial from a customer with approximately 150 characters', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Testimonials Single', + 'slug' => 'woocommerce-blocks/testimonials-single', + 'images_total' => 1, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'A ‘brewtiful’ experience :-)', 'woocommerce' ), + 'ai_prompt' => __( 'A two words title that advertises the testimonial', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'Exceptional flavors, sustainable choices. The carefully curated collection of coffee pots and accessories turned my kitchen into a haven of style and taste.', 'woocommerce' ), + 'ai_prompt' => __( 'A description of the testimonial with at least 225 characters', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Featured Category Cover Image', + 'slug' => 'woocommerce-blocks/featured-category-cover-image', + 'images_total' => 1, + 'images_format' => 'landscape', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Sit back and relax', 'woocommerce' ), + 'ai_prompt' => __( 'A description for a product with at least 20 characters', 'woocommerce' ), + ], + ], + 'descriptions' => [ + [ + 'default' => __( 'With a wide range of designer chairs to elevate your living space.', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the products with at least 55 characters', 'woocommerce' ), + ], + ], + 'buttons' => [ + [ + 'default' => __( 'Shop chairs', 'woocommerce' ), + 'ai_prompt' => __( 'A two words button text to go to the shop page', 'woocommerce' ), + ], + ], + ], + ], + [ + 'name' => 'Product Collection: Featured Products 5 Columns', + 'slug' => 'woocommerce-blocks/product-collection-featured-products-5-columns', + 'content' => [ + 'titles' => [ + [ + 'default' => __( 'Shop new arrivals', 'woocommerce' ), + 'ai_prompt' => __( 'An impact phrase that advertises the newest additions to the store with at least 20 characters', 'woocommerce' ), + ], + ], + ], + ], + ]; + } +} diff --git a/plugins/woocommerce/src/Blocks/AIContent/PatternsHelper.php b/plugins/woocommerce/src/Blocks/AIContent/PatternsHelper.php index 26d8fb28b44..d6eceadb97e 100644 --- a/plugins/woocommerce/src/Blocks/AIContent/PatternsHelper.php +++ b/plugins/woocommerce/src/Blocks/AIContent/PatternsHelper.php @@ -63,7 +63,7 @@ class PatternsHelper { /** * Upsert the patterns AI data. * - * @param array $patterns_dictionary The patterns dictionary. + * @param array $patterns_dictionary The patterns' dictionary. * * @return WP_Error|null */ @@ -92,18 +92,12 @@ class PatternsHelper { * @return array|WP_Error Returns pattern dictionary or WP_Error on failure. */ public static function get_patterns_dictionary( $pattern_slug = null ) { - $patterns_dictionary_file = plugin_dir_path( __FILE__ ) . 'dictionary.json'; + $default_patterns_dictionary = PatternsDictionary::get(); - if ( ! file_exists( $patterns_dictionary_file ) ) { + if ( empty( $default_patterns_dictionary ) ) { return new WP_Error( 'missing_patterns_dictionary', __( 'The patterns dictionary is missing.', 'woocommerce' ) ); } - $default_patterns_dictionary = wp_json_file_decode( $patterns_dictionary_file, array( 'associative' => true ) ); - - if ( json_last_error() !== JSON_ERROR_NONE ) { - return new WP_Error( 'json_decode_error', __( 'Error decoding JSON.', 'woocommerce' ) ); - } - $patterns_dictionary = ''; $ai_connection_allowed = get_option( 'woocommerce_blocks_allow_ai_connection' ); diff --git a/plugins/woocommerce/src/Blocks/AIContent/UpdatePatterns.php b/plugins/woocommerce/src/Blocks/AIContent/UpdatePatterns.php index bc0c90b0b8a..d9e792e85eb 100644 --- a/plugins/woocommerce/src/Blocks/AIContent/UpdatePatterns.php +++ b/plugins/woocommerce/src/Blocks/AIContent/UpdatePatterns.php @@ -402,13 +402,13 @@ class UpdatePatterns { * @return mixed|WP_Error|null */ public static function get_patterns_dictionary() { - $patterns_dictionary = plugin_dir_path( __FILE__ ) . 'dictionary.json'; + $patterns_dictionary = PatternsDictionary::get(); - if ( ! file_exists( $patterns_dictionary ) ) { + if ( empty( $patterns_dictionary ) ) { return new WP_Error( 'missing_patterns_dictionary', __( 'The patterns dictionary is missing.', 'woocommerce' ) ); } - return wp_json_file_decode( $patterns_dictionary, array( 'associative' => true ) ); + return $patterns_dictionary; } /** diff --git a/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php b/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php index 0a00b68051f..20e0549f8e3 100644 --- a/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php +++ b/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php @@ -26,7 +26,7 @@ class UpdateProducts { 'price' => 249, ], [ - 'title' => 'Black and White Summer Portrait', + 'title' => 'Black and White', 'image' => 'assets/images/pattern-placeholders/white-black-black-and-white-photograph-monochrome-photography.jpg', 'description' => 'This 24" x 30" high-quality print just exudes summer. Hang it on the wall and forget about the world outside.', 'price' => 115, @@ -113,7 +113,7 @@ class UpdateProducts { $products_to_create = max( 0, 6 - $real_products_count - $dummy_products_count ); while ( $products_to_create > 0 ) { $this->create_new_product( self::DUMMY_PRODUCTS[ $products_to_create - 1 ] ); - $products_to_create--; + --$products_to_create; } // Identify dummy products that need to have their content updated. @@ -327,7 +327,7 @@ class UpdateProducts { public function assign_ai_selected_images_to_dummy_products( $dummy_products_to_update, $ai_selected_images ) { $products_information_list = []; $dummy_products_count = count( $dummy_products_to_update ); - for ( $i = 0; $i < $dummy_products_count; $i ++ ) { + for ( $i = 0; $i < $dummy_products_count; $i++ ) { $image_src = $ai_selected_images[ $i ]['URL'] ?? ''; if ( wc_is_valid_url( $image_src ) ) { @@ -396,7 +396,7 @@ class UpdateProducts { $ai_request_retries = 0; $success = false; while ( $ai_request_retries < 5 && ! $success ) { - $ai_request_retries ++; + ++$ai_request_retries; $ai_response = $ai_connection->fetch_ai_response( $token, $formatted_prompt, 30 ); if ( is_wp_error( $ai_response ) ) { continue; @@ -464,7 +464,7 @@ class UpdateProducts { $this->product_update( $product, $product_image_id, self::DUMMY_PRODUCTS[ $i ]['title'], self::DUMMY_PRODUCTS[ $i ]['description'], self::DUMMY_PRODUCTS[ $i ]['price'] ); - $i++; + ++$i; } } diff --git a/plugins/woocommerce/src/Blocks/AIContent/dictionary.json b/plugins/woocommerce/src/Blocks/AIContent/dictionary.json deleted file mode 100644 index 452f8aa2fb6..00000000000 --- a/plugins/woocommerce/src/Blocks/AIContent/dictionary.json +++ /dev/null @@ -1,656 +0,0 @@ -[ - { - "name": "Banner", - "slug": "woocommerce-blocks/banner", - "images_total": 1, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "Up to 60% off", - "ai_prompt": "A four words title advertising the sale" - } - ], - "descriptions": [ - { - "default": "Holiday Sale", - "ai_prompt": "A two words label with the sale name" - }, - { - "default": "Get your favorite vinyl at record-breaking prices.", - "ai_prompt": "The main description of the sale with at least 65 characters" - } - ], - "buttons": [ - { - "default": "Shop vinyl records", - "ai_prompt": "A 3 words button text to go to the sale page" - } - ] - } - }, - { - "name": "Discount Banner", - "slug": "woocommerce-blocks/discount-banner", - "content": { - "descriptions": [ - { - "default": "Select products", - "ai_prompt": "A two words description of the products on sale" - } - ] - } - }, - { - "name": "Discount Banner with Image", - "slug": "woocommerce-blocks/discount-banner-with-image", - "images_total": 1, - "images_format": "landscape", - "content": { - "descriptions": [ - { - "default": "Select products", - "ai_prompt": "A two words description of the products on sale" - } - ] - } - }, - { - "name": "Featured Category Focus", - "slug": "woocommerce-blocks/featured-category-focus", - "images_total": 1, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "Black and white high-quality prints", - "ai_prompt": "The four words title of the featured category related to the following image description: {image.0}" - } - ], - "buttons": [ - { - "default": "Shop prints", - "ai_prompt": "A two words button text to go to the featured category" - } - ] - } - }, - { - "name": "Featured Category Triple", - "slug": "woocommerce-blocks/featured-category-triple", - "images_total": 3, - "images_format": "portrait", - "content": { - "titles": [ - { - "default": "Home decor", - "ai_prompt": "A one-word graphic title that encapsulates the essence of the business, inspired by the following image description: {image.0} and the nature of the business. The title should reflect the key elements and characteristics of the business, as portrayed in the image" - }, - { - "default": "Retro photography", - "ai_prompt": "A two-words graphic title that encapsulates the essence of the business, inspired by the following image description: {image.1} and the nature of the business. The title should reflect the key elements and characteristics of the business, as portrayed in the image" - }, - { - "default": "Handmade gifts", - "ai_prompt": "A two-words graphic title that encapsulates the essence of the business, inspired by the following image description: {image.2} and the nature of the business. The title should reflect the key elements and characteristics of the business, as portrayed in the image" - } - ] - } - }, - { - "name": "Featured Products: Fresh & Tasty", - "slug": "woocommerce-blocks/featured-products-fresh-and-tasty", - "images_total": 4, - "images_format": "portrait", - "content": { - "titles": [ - { - "default": "Fresh & tasty goods", - "ai_prompt": "The title of the featured products with at least 20 characters" - } - ], - "descriptions": [ - { - "default": "Sweet Organic Lemons", - "ai_prompt": "The three words description of the featured product related to the following image description: {image.0}" - }, - { - "default": "Fresh Organic Tomatoes", - "ai_prompt": "The three words description of the featured product related to the following image description: {image.1}" - }, - { - "default": "Fresh Lettuce (Washed)", - "ai_prompt": "The three words description of the featured product related to the following image description: {image.2}" - }, - { - "default": "Russet Organic Potatoes", - "ai_prompt": "The three words description of the featured product related to the following image description: {image.3}" - } - ] - } - }, - { - "name": "Hero Product 3 Split", - "slug": "woocommerce-blocks/hero-product-3-split", - "images_total": 1, - "images_format": "portrait", - "content": { - "titles": [ - { - "default": "Timeless elegance", - "ai_prompt": "Write a two words title for advertising the store" - }, - { - "default": "Durable glass", - "ai_prompt": "Write a two words title for advertising the store" - }, - { - "default": "Versatile charm", - "ai_prompt": "Write a two words title for advertising the store" - }, - { - "default": "New: Retro Glass Jug", - "ai_prompt": "Write a title with less than 20 characters for advertising the store" - } - ], - "descriptions": [ - { - "default": "Elevate your table with a 330ml Retro Glass Jug, blending classic design and durable hardened glass.", - "ai_prompt": "Write a text with approximately 130 characters, to describe a product the business is selling" - }, - { - "default": "Crafted from resilient thick glass, this jug ensures lasting quality, making it perfect for everyday use with a touch of vintage charm.", - "ai_prompt": "Write a text with approximately 130 characters, to describe a product the business is selling" - }, - { - "default": "The Retro Glass Jug's classic silhouette effortlessly complements any setting, making it the ideal choice for serving beverages with style and flair.", - "ai_prompt": "Write a long text, with at least 130 characters, to describe a product the business is selling" - } - ] - } - }, - { - "name": "Hero Product Chessboard", - "slug": "woocommerce-blocks/hero-product-chessboard", - "images_total": 2, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "Quality Materials", - "ai_prompt": "A two words title describing the first displayed product feature" - }, - { - "default": "Unique design", - "ai_prompt": "A two words title describing the second displayed product feature" - }, - { - "default": "Make your house feel like home", - "ai_prompt": "A two words title describing the fourth displayed product feature" - } - ], - "descriptions": [ - { - "default": "We use only the highest-quality materials in our products, ensuring that they look great and last for years to come.", - "ai_prompt": "A description of the product feature with at least 115 characters" - }, - { - "default": "From bold prints to intricate details, our products are a perfect combination of style and function.", - "ai_prompt": "A description of the product feature with at least 115 characters" - }, - { - "default": "Add a touch of charm and coziness this holiday season with a wide selection of hand-picked decorations — from minimalist vases to designer furniture.", - "ai_prompt": "A description of the product feature with at least 115 characters" - } - ], - "buttons": [ - { - "default": "Shop home decor", - "ai_prompt": "A two words button text to go to the product page" - } - ] - } - }, - { - "name": "Hero Product Split", - "slug": "woocommerce-blocks/hero-product-split", - "images_total": 1, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "Keep dry with 50% off rain jackets", - "ai_prompt": "An impact phrase that advertises the product the store is selling with at least 35 characters" - } - ] - } - }, - { - "name": "Just Arrived Full Hero", - "slug": "woocommerce-blocks/just-arrived-full-hero", - "images_total": 1, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "Sound like no other", - "ai_prompt": "An impact phrase that advertises the displayed product collection with at least 10 characters" - } - ], - "descriptions": [ - { - "default": "Experience your music like never before with our latest generation of hi-fidelity headphones.", - "ai_prompt": "A description of the product collection with at least 35 characters" - } - ], - "buttons": [ - { - "default": "Shop now", - "ai_prompt": "A two words button text to go to the product collection page" - } - ] - } - }, - { - "name": "Product Collection Banner", - "slug": "woocommerce-blocks/product-collection-banner", - "images_total": 1, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "Brand New for the Holidays", - "ai_prompt": "An impact phrase that advertises the displayed product collection with at least 25 characters related to the following image description: {image.0}" - } - ], - "descriptions": [ - { - "default": "Check out our brand new collection of holiday products and find the right gift for anyone.", - "ai_prompt": "A description of the product collection with at least 90 characters" - } - ] - } - }, - { - "name": "Product Collections Featured Collection", - "slug": "woocommerce-blocks/product-collections-featured-collection", - "content": { - "titles": [ - { - "default": "This week's popular products", - "ai_prompt": "An impact phrase that advertises the displayed product collection with at least 30 characters" - } - ] - } - }, - { - "name": "Product Collections Featured Collections", - "slug": "woocommerce-blocks/product-collections-featured-collections", - "images_total": 4, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "Tech gifts under $100", - "ai_prompt": "An impact phrase that advertises the product collection with at least 20 characters related to the following image descriptions: {image.0}, {image.1}" - }, - { - "default": "For the gamers", - "ai_prompt": "An impact phrase that advertises the product collection with at least 15 characters related to the following image descriptions: {image.2}, {image.3}" - } - ], - "buttons": [ - { - "default": "Shop tech", - "ai_prompt": "A two words button text to go to the product collection page" - }, - { - "default": "Shop games", - "ai_prompt": "A two words button text to go to the product collection page" - } - ] - } - }, - { - "name": "Product Collections Newest Arrivals", - "slug": "woocommerce-blocks/product-collections-newest-arrivals", - "content": { - "titles": [ - { - "default": "Our newest arrivals", - "ai_prompt": "An impact phrase that advertises the displayed product collection with at least 20 characters" - } - ], - "buttons": [ - { - "default": "More new products", - "ai_prompt": "The button text to go to the product collection page with at least 15 characters" - } - ] - } - }, - { - "name": "Product Collection 4 Columns", - "slug": "woocommerce-blocks/product-collection-4-columns", - "content": { - "titles": [ - { - "default": "Staff picks", - "ai_prompt": "An impact phrase that advertises the displayed product collection with at least 20 characters" - } - ] - } - }, - { - "name": "Product Collection 5 Columns", - "slug": "woocommerce-blocks/product-collection-5-columns", - "content": { - "titles": [ - { - "default": "Our latest and greatest", - "ai_prompt": "An impact phrase with that advertises the product collection with at least 20 characters" - } - ] - } - }, - { - "name": "Product Gallery", - "slug": "woocommerce-blocks/product-query-product-gallery", - "content": { - "titles": [ - { - "default": "Bestsellers", - "ai_prompt": "An impact phrase that advertises the featured products with at least 10 characters" - } - ] - } - }, - { - "name": "Featured Products 2 Columns", - "slug": "woocommerce-blocks/featured-products-2-cols", - "content": { - "titles": [ - { - "default": "Fan favorites", - "ai_prompt": "An impact phrase that advertises the featured products with at least 10 characters" - } - ], - "descriptions": [ - { - "default": "Get ready to start the season right. All the fan favorites in one place at the best price.", - "ai_prompt": "A description of the featured products with at least 90 characters" - } - ], - "buttons": [ - { - "default": "Shop All", - "ai_prompt": "A two words button text to go to the featured products page" - } - ] - } - }, - { - "name": "Product Hero 2 Column 2 Row", - "slug": "woocommerce-blocks/product-hero-2-col-2-row", - "images_total": 2, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "The Eden Jacket", - "ai_prompt": "A three words title that advertises a product related to the following image description: {image.0}" - }, - { - "default": "100% Woolen", - "ai_prompt": "A two words title that advertises a product feature" - }, - { - "default": "Fits your wardrobe", - "ai_prompt": "A three words title that advertises a product feature" - }, - { - "default": "Versatile", - "ai_prompt": "An one word title that advertises a product feature" - }, - { - "default": "Normal Fit", - "ai_prompt": "A two words title that advertises a product feature" - } - ], - "descriptions": [ - { - "default": "Perfect for any look featuring a mid-rise, relax fitting silhouette.", - "ai_prompt": "The description of a product with at least 65 characters related to the following image: {image.0}" - }, - { - "default": "Reflect your fashionable style.", - "ai_prompt": "The description of a product feature with at least 30 characters" - }, - { - "default": "Half tuck into your pants or layer over.", - "ai_prompt": "The description of a product feature with at least 30 characters" - }, - { - "default": "Button-down front for any type of mood or look.", - "ai_prompt": "The description of a product feature with at least 30 characters" - }, - { - "default": "42% Cupro 34% Linen 24% Viscose", - "ai_prompt": "The description of a product feature with at least 30 characters" - } - ], - "buttons": [ - { - "default": "View product", - "ai_prompt": "A two words button text to go to the product page" - } - ] - } - }, - { - "name": "Shop by Price", - "slug": "woocommerce-blocks/shop-by-price", - "content": { - "titles": [ - { - "default": "Outdoor Furniture & Accessories", - "ai_prompt": "An impact phrase that advertises the first product collection with at least 30 characters" - }, - { - "default": "Summer Dinning", - "ai_prompt": "An impact phrase that advertises the second product collection with at least 20 characters" - }, - { - "default": "Women's Styles", - "ai_prompt": "An impact phrase that advertises the third product collection with at least 20 characters" - }, - { - "default": "Kids' Styles", - "ai_prompt": "An impact phrase that advertises the fourth product collection with at least 20 characters" - } - ] - } - }, - { - "name": "Small Discount Banner with Image", - "slug": "woocommerce-blocks/small-discount-banner-with-image", - "images_total": 1, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "Chairs", - "ai_prompt": "A single word that advertises the product and is related to the following image description: {image.0}" - } - ] - } - }, - { - "name": "Social: Follow us on social media", - "slug": "woocommerce-blocks/social-follow-us-in-social-media", - "images_total": 4, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "Stay in the loop", - "ai_prompt": "A phrase that advertises the social media accounts of the store with at least 25 characters" - } - ] - } - }, - { - "name": "Alternating Image and Text", - "slug": "woocommerce-blocks/alt-image-and-text", - "images_total": 2, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "Our products", - "ai_prompt": "A two words impact phrase that advertises the products" - }, - { - "default": "Sustainable blends, stylish accessories", - "ai_prompt": "An impact phrase that advertises the products with at least 40 characters and related to the following image description: {image.0}" - }, - { - "default": "About us", - "ai_prompt": "A two words impact phrase that advertises the brand" - }, - { - "default": "Committed to a greener lifestyle", - "ai_prompt": "An impact phrase that advertises the brand with at least 50 characters related to the following image description: {image.1}" - } - ], - "descriptions": [ - { - "default": "Indulge in the finest organic coffee beans, teas, and hand-picked accessories, all locally sourced and sustainable for a mindful lifestyle.", - "ai_prompt": "A description of the products with at least 180 characters" - }, - { - "default": "Our passion is crafting mindful moments with locally sourced, organic, and sustainable products. We're more than a store; we're your path to a community-driven, eco-friendly lifestyle that embraces premium quality.", - "ai_prompt": "A description of the products with at least 180 characters" - }, - { - "default": "Locally sourced ingredients", - "ai_prompt": "A three word description of the products" - }, - { - "default": "Premium organic blends", - "ai_prompt": "A three word description of the products" - }, - { - "default": "Hand-picked accessories", - "ai_prompt": "A three word description of the products" - }, - { - "default": "Sustainable business practices", - "ai_prompt": "A three word description of the products" - } - ], - "buttons": [ - { - "default": "Meet us", - "ai_prompt": "A two words button text to go to the product page" - } - ] - } - }, - { - "name": "Testimonials 3 Columns", - "slug": "woocommerce-blocks/testimonials-3-columns", - "content": { - "titles": [ - { - "default": "Eclectic finds, ethical delights", - "ai_prompt": "Write a short title advertising a testimonial from a customer" - }, - { - "default": "Sip, Shop, Savor", - "ai_prompt": "Write a short title advertising a testimonial from a customer" - }, - { - "default": "LOCAL LOVE", - "ai_prompt": "Write a short title advertising a testimonial from a customer" - }, - { - "default": "What our customers say", - "ai_prompt": "Write just 4 words to advertise testimonials from customers" - } - ], - "descriptions": [ - { - "default": "Transformed my daily routine with unique, eco-friendly treasures. Exceptional quality and service. Proud to support a store that aligns with my values.", - "ai_prompt": "Write the testimonial from a customer with approximately 150 characters" - }, - { - "default": "The organic coffee beans are a revelation. Each sip feels like a journey. Beautifully crafted accessories add a touch of elegance to my home.", - "ai_prompt": "Write the testimonial from a customer with approximately 150 characters" - }, - { - "default": "From sustainably sourced teas to chic vases, this store is a treasure trove. Love knowing my purchases contribute to a greener planet.", - "ai_prompt": "Write the testimonial from a customer with approximately 150 characters" - } - ] - } - }, - { - "name": "Testimonials Single", - "slug": "woocommerce-blocks/testimonials-single", - "images_total": 1, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "A ‘brewtiful’ experience :-)", - "ai_prompt": "A two words title that advertises the testimonial" - } - ], - "descriptions": [ - { - "default": "Exceptional flavors, sustainable choices. The carefully curated collection of coffee pots and accessories turned my kitchen into a haven of style and taste.", - "ai_prompt": "A description of the testimonial with at least 225 characters" - } - ] - } - }, - { - "name": "Featured Category Cover Image", - "slug": "woocommerce-blocks/featured-category-cover-image", - "images_total": 1, - "images_format": "landscape", - "content": { - "titles": [ - { - "default": "Sit back and relax", - "ai_prompt": "A description for a product with at least 20 characters" - } - ], - "descriptions": [ - { - "default": "With a wide range of designer chairs to elevate your living space.", - "ai_prompt": "An impact phrase that advertises the products with at least 55 characters" - } - ], - "buttons": [ - { - "default": "Shop chairs", - "ai_prompt": "A two words button text to go to the shop page" - } - ] - } - }, - { - "name": "Product Collection: Featured Products 5 Columns", - "slug": "woocommerce-blocks/product-collection-featured-products-5-columns", - "content": { - "titles": [ - { - "default": "Shop new arrivals", - "ai_prompt": "An impact phrase that advertises the newest additions to the store with at least 20 characters" - } - ] - } - } -] diff --git a/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php b/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php index c1c6824aeba..453417c338c 100644 --- a/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php +++ b/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php @@ -3,6 +3,7 @@ namespace Automattic\WooCommerce\Blocks\Assets; use Automattic\WooCommerce\Blocks\Package; use Automattic\WooCommerce\Blocks\Domain\Services\Hydration; +use Automattic\WooCommerce\Internal\Logging\RemoteLogger; use Exception; use InvalidArgumentException; @@ -89,6 +90,7 @@ class AssetDataRegistry { 'dateFormat' => wc_date_format(), 'homeUrl' => esc_url( home_url( '/' ) ), 'locale' => $this->get_locale_data(), + 'isRemoteLoggingEnabled' => wc_get_container()->get( RemoteLogger::class )->is_remote_logging_allowed(), 'dashboardUrl' => wc_get_account_endpoint_url( 'dashboard' ), 'orderStatuses' => $this->get_order_statuses(), 'placeholderImgSrc' => wc_placeholder_img_src(), diff --git a/plugins/woocommerce/src/Blocks/AssetsController.php b/plugins/woocommerce/src/Blocks/AssetsController.php index 1bb55c0f0c1..13236dc8145 100644 --- a/plugins/woocommerce/src/Blocks/AssetsController.php +++ b/plugins/woocommerce/src/Blocks/AssetsController.php @@ -64,11 +64,11 @@ final class AssetsController { $this->api->register_script( 'wc-price-format', 'assets/client/blocks/price-format.js', array(), false ); // Vendor scripts for blocks frontends (not including cart and checkout). - $this->api->register_script( 'wc-blocks-frontend-vendors', $this->api->get_block_asset_build_path( 'wc-blocks-frontend-vendors-frontend' ), array(), false ); + $this->api->register_script( 'wc-blocks-frontend-vendors', $this->api->get_block_asset_build_path( 'wc-blocks-frontend-vendors-frontend' ), array(), true ); // Cart and checkout frontend scripts. - $this->api->register_script( 'wc-cart-checkout-vendors', $this->api->get_block_asset_build_path( 'wc-cart-checkout-vendors-frontend' ), array(), false ); - $this->api->register_script( 'wc-cart-checkout-base', $this->api->get_block_asset_build_path( 'wc-cart-checkout-base-frontend' ), array(), false ); + $this->api->register_script( 'wc-cart-checkout-vendors', $this->api->get_block_asset_build_path( 'wc-cart-checkout-vendors-frontend' ), array(), true ); + $this->api->register_script( 'wc-cart-checkout-base', $this->api->get_block_asset_build_path( 'wc-cart-checkout-base-frontend' ), array(), true ); $this->api->register_script( 'wc-blocks-checkout', 'assets/client/blocks/blocks-checkout.js' ); $this->api->register_script( 'wc-blocks-components', 'assets/client/blocks/blocks-components.js' ); diff --git a/plugins/woocommerce/src/Blocks/BlockTemplatesController.php b/plugins/woocommerce/src/Blocks/BlockTemplatesController.php index 28017a64677..b8318a60d44 100644 --- a/plugins/woocommerce/src/Blocks/BlockTemplatesController.php +++ b/plugins/woocommerce/src/Blocks/BlockTemplatesController.php @@ -1,7 +1,6 @@ fallback_template ) ) { return null; } $wp_query_args = array( - 'post_name__in' => array( ProductCatalogTemplate::SLUG, $slug ), + 'post_name__in' => array( $registered_template->fallback_template, $slug ), 'post_type' => $template_type, 'post_status' => array( 'auto-draft', 'draft', 'publish', 'trash' ), 'no_found_rows' => true, @@ -98,12 +102,12 @@ class BlockTemplatesController { $posts = $template_query->posts; // If we have more than one result from the query, it means that the current template is present in the db (has - // been customized by the user) and we should not return the `archive-product` template. + // been customized by the user) and we should not return the fallback template. if ( count( $posts ) > 1 ) { return null; } - if ( count( $posts ) > 0 && ProductCatalogTemplate::SLUG === $posts[0]->post_name ) { + if ( count( $posts ) > 0 && $registered_template->fallback_template === $posts[0]->post_name ) { $template = _build_block_template_result_from_post( $posts[0] ); if ( ! is_wp_error( $template ) ) { @@ -121,26 +125,21 @@ class BlockTemplatesController { } /** - * Adds the `archive-product` template to the `taxonomy-product_cat`, `taxonomy-product_tag`, `taxonomy-attribute` - * templates to be able to fall back to it. + * Adds the fallback template to the template hierarchy. * * @param array $template_hierarchy A list of template candidates, in descending order of priority. */ - public function add_archive_product_to_eligible_for_fallback_templates( $template_hierarchy ) { + public function add_fallback_template_to_hierarchy( $template_hierarchy ) { $template_slugs = array_map( '_strip_template_file_suffix', $template_hierarchy ); - $templates_eligible_for_fallback = array_filter( - $template_slugs, - function ( $template_slug ) { - return BlockTemplateUtils::template_is_eligible_for_product_archive_fallback( $template_slug ); + foreach ( $template_slugs as $template_slug ) { + $template = BlockTemplateUtils::get_template( $template_slug ); + if ( $template && isset( $template->fallback_template ) ) { + $template_hierarchy[] = $template->fallback_template; } - ); - - if ( count( $templates_eligible_for_fallback ) > 0 ) { - $template_hierarchy[] = ProductCatalogTemplate::SLUG; } return $template_hierarchy; @@ -233,10 +232,12 @@ class BlockTemplatesController { list( $template_id, $template_slug ) = $template_name_parts; - // If the theme has an archive-product.html template, but not a taxonomy-product_cat/tag/attribute.html template let's use the themes archive-product.html template. - if ( BlockTemplateUtils::template_is_eligible_for_product_archive_fallback_from_theme( $template_slug ) ) { - $template_path = BlockTemplateUtils::get_theme_template_path( ProductCatalogTemplate::SLUG ); - $template_object = BlockTemplateUtils::create_new_block_template_object( $template_path, $template_type, $template_slug, true ); + // If the template is not present in the theme but its fallback template is, + // let's use the theme's fallback template. + if ( BlockTemplateUtils::template_is_eligible_for_fallback_from_theme( $template_slug ) ) { + $registered_template = BlockTemplateUtils::get_template( $template_slug ); + $template_path = BlockTemplateUtils::get_theme_template_path( $registered_template->fallback_template ); + $template_object = BlockTemplateUtils::create_new_block_template_object( $template_path, $template_type, $template_slug, true ); return BlockTemplateUtils::build_template_result_from_file( $template_object, $template_type ); } @@ -441,7 +442,7 @@ class BlockTemplatesController { continue; } - if ( BlockTemplateUtils::template_is_eligible_for_product_archive_fallback_from_db( $template_slug, $already_found_templates ) ) { + if ( BlockTemplateUtils::template_is_eligible_for_fallback_from_db( $template_slug, $already_found_templates ) ) { $template = clone BlockTemplateUtils::get_fallback_template_from_db( $template_slug, $already_found_templates ); $template_id = explode( '//', $template->id ); $template->id = $template_id[0] . '//' . $template_slug; @@ -452,10 +453,12 @@ class BlockTemplatesController { continue; } - // If the theme has an archive-product.html template, but not a taxonomy-product_cat/tag/attribute.html template let's use the themes archive-product.html template. - if ( BlockTemplateUtils::template_is_eligible_for_product_archive_fallback_from_theme( $template_slug ) ) { - $template_file = BlockTemplateUtils::get_theme_template_path( ProductCatalogTemplate::SLUG ); - $templates[] = BlockTemplateUtils::create_new_block_template_object( $template_file, $template_type, $template_slug, true ); + // If the template is not present in the theme but its fallback template is, + // let's use the theme's fallback template. + if ( BlockTemplateUtils::template_is_eligible_for_fallback_from_theme( $template_slug ) ) { + $registered_template = BlockTemplateUtils::get_template( $template_slug ); + $template_file = BlockTemplateUtils::get_theme_template_path( $registered_template->fallback_template ); + $templates[] = BlockTemplateUtils::create_new_block_template_object( $template_file, $template_type, $template_slug, true ); continue; } diff --git a/plugins/woocommerce/src/Blocks/BlockTemplatesRegistry.php b/plugins/woocommerce/src/Blocks/BlockTemplatesRegistry.php index b9bb10582b3..76cec00b179 100644 --- a/plugins/woocommerce/src/Blocks/BlockTemplatesRegistry.php +++ b/plugins/woocommerce/src/Blocks/BlockTemplatesRegistry.php @@ -10,8 +10,6 @@ use Automattic\WooCommerce\Blocks\Templates\CartTemplate; use Automattic\WooCommerce\Blocks\Templates\CheckoutTemplate; use Automattic\WooCommerce\Blocks\Templates\CheckoutHeaderTemplate; use Automattic\WooCommerce\Blocks\Templates\ComingSoonTemplate; -use Automattic\WooCommerce\Blocks\Templates\ComingSoonEntireSiteTemplate; -use Automattic\WooCommerce\Blocks\Templates\ComingSoonStoreOnlyTemplate; use Automattic\WooCommerce\Blocks\Templates\OrderConfirmationTemplate; use Automattic\WooCommerce\Blocks\Templates\ProductAttributeTemplate; use Automattic\WooCommerce\Blocks\Templates\ProductCatalogTemplate; @@ -19,6 +17,7 @@ use Automattic\WooCommerce\Blocks\Templates\ProductCategoryTemplate; use Automattic\WooCommerce\Blocks\Templates\ProductTagTemplate; use Automattic\WooCommerce\Blocks\Templates\ProductSearchResultsTemplate; use Automattic\WooCommerce\Blocks\Templates\SingleProductTemplate; +use Automattic\WooCommerce\Blocks\Templates\ProductFiltersTemplate; use Automattic\WooCommerce\Blocks\Templates\ProductFiltersOverlayTemplate; /** @@ -59,10 +58,14 @@ class BlockTemplatesRegistry { } if ( BlockTemplateUtils::supports_block_templates( 'wp_template_part' ) ) { $template_parts = array( - MiniCartTemplate::SLUG => new MiniCartTemplate(), - CheckoutHeaderTemplate::SLUG => new CheckoutHeaderTemplate(), - ProductFiltersOverlayTemplate::SLUG => new ProductFiltersOverlayTemplate(), + MiniCartTemplate::SLUG => new MiniCartTemplate(), + CheckoutHeaderTemplate::SLUG => new CheckoutHeaderTemplate(), ); + + if ( Features::is_enabled( 'experimental-blocks' ) ) { + $template_parts[ ProductFiltersTemplate::SLUG ] = new ProductFiltersTemplate(); + $template_parts[ ProductFiltersOverlayTemplate::SLUG ] = new ProductFiltersOverlayTemplate(); + } } else { $template_parts = array(); } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AbstractBlock.php b/plugins/woocommerce/src/Blocks/BlockTypes/AbstractBlock.php index d73992831b3..dbe9007d6a8 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/AbstractBlock.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/AbstractBlock.php @@ -450,14 +450,16 @@ abstract class AbstractBlock { 'wordCountType' => _x( 'words', 'Word count type. Do not translate!', 'woocommerce' ), ]; if ( is_admin() && ! WC()->is_rest_api_request() ) { - $wc_blocks_config = array_merge( + $product_counts = wp_count_posts( 'product' ); + $published_products = isset( $product_counts->publish ) ? $product_counts->publish : 0; + $wc_blocks_config = array_merge( $wc_blocks_config, [ // Note that while we don't have a consolidated way of doing feature-flagging // we are borrowing from the WC Admin Features implementation. Also note we cannot // use the wcAdminFeatures global because it's not always enqueued in the context of blocks. 'experimentalBlocksEnabled' => Features::is_enabled( 'experimental-blocks' ), - 'productCount' => array_sum( (array) wp_count_posts( 'product' ) ), + 'productCount' => $published_products, ] ); } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php index c12e3fbd451..1237a0e69c5 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php @@ -192,7 +192,7 @@ class AddToCartForm extends AbstractBlock { } /** - * It isn't necessary register block assets because it is a server side block. + * It isn't necessary to register block assets because it is a server side block. */ protected function register_block_type_assets() { return null; diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Cart.php b/plugins/woocommerce/src/Blocks/BlockTypes/Cart.php index b3cabefb20c..11f8bdb2d26 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/Cart.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/Cart.php @@ -245,7 +245,6 @@ class Cart extends AbstractBlock { $this->asset_data_registry->add( 'hasDarkEditorStyleSupport', current_theme_supports( 'dark-editor-style' ) ); $this->asset_data_registry->register_page_id( isset( $attributes['checkoutPageId'] ) ? $attributes['checkoutPageId'] : 0 ); $this->asset_data_registry->add( 'isBlockTheme', wc_current_theme_is_fse_theme() ); - $this->asset_data_registry->add( 'activeShippingZones', CartCheckoutUtils::get_shipping_zones() ); $pickup_location_settings = LocalPickupUtils::get_local_pickup_settings(); $this->asset_data_registry->add( 'localPickupEnabled', $pickup_location_settings['enabled'] ); diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Checkout.php b/plugins/woocommerce/src/Blocks/BlockTypes/Checkout.php index 78e44abfdee..60737382791 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/Checkout.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/Checkout.php @@ -36,9 +36,9 @@ class Checkout extends AbstractBlock { // This prevents the page redirecting when the cart is empty. This is so the editor still loads the page preview. add_filter( 'woocommerce_checkout_redirect_empty_cart', - function( $return ) { + function ( $redirect_empty_cart ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended - return isset( $_GET['_wp-find-template'] ) ? false : $return; + return isset( $_GET['_wp-find-template'] ) ? false : $redirect_empty_cart; } ); @@ -94,10 +94,17 @@ class Checkout extends AbstractBlock { * @return array|string */ protected function get_block_type_script( $key = null ) { + $dependencies = []; + + // Load password strength meter script asynchronously if needed. + if ( ! is_user_logged_in() && 'no' === get_option( 'woocommerce_registration_generate_password' ) ) { + $dependencies[] = 'zxcvbn-async'; + } + $script = [ 'handle' => 'wc-' . $this->block_name . '-block-frontend', 'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ), - 'dependencies' => [], + 'dependencies' => $dependencies, ]; return $key ? $script[ $key ] : $script; } @@ -354,6 +361,7 @@ class Checkout extends AbstractBlock { $this->asset_data_registry->add( 'displayCartPricesIncludingTax', 'incl' === get_option( 'woocommerce_tax_display_cart' ) ); $this->asset_data_registry->add( 'displayItemizedTaxes', 'itemized' === get_option( 'woocommerce_tax_total_display' ) ); $this->asset_data_registry->add( 'forcedBillingAddress', 'billing_only' === get_option( 'woocommerce_ship_to_destination' ) ); + $this->asset_data_registry->add( 'generatePassword', filter_var( get_option( 'woocommerce_registration_generate_password' ), FILTER_VALIDATE_BOOLEAN ) ); $this->asset_data_registry->add( 'taxesEnabled', wc_tax_enabled() ); $this->asset_data_registry->add( 'couponsEnabled', wc_coupons_enabled() ); $this->asset_data_registry->add( 'shippingEnabled', wc_shipping_enabled() ); @@ -377,7 +385,7 @@ class Checkout extends AbstractBlock { $shipping_methods = WC()->shipping()->get_shipping_methods(); $formatted_shipping_methods = array_reduce( $shipping_methods, - function( $acc, $method ) { + function ( $acc, $method ) { if ( in_array( $method->id, LocalPickupUtils::get_local_pickup_method_ids(), true ) ) { return $acc; } @@ -405,7 +413,7 @@ class Checkout extends AbstractBlock { $payment_methods = $this->get_enabled_payment_gateways(); $formatted_payment_methods = array_reduce( $payment_methods, - function( $acc, $method ) { + function ( $acc, $method ) { $acc[] = [ 'id' => $method->id, 'title' => $method->method_title, @@ -427,7 +435,7 @@ class Checkout extends AbstractBlock { $all_plugins = \get_plugins(); // Note that `get_compatible_plugins_for_feature` calls `get_plugins` internally, so this is already in cache. $incompatible_extensions = array_reduce( $declared_extensions['incompatible'], - function( $acc, $item ) use ( $all_plugins ) { + function ( $acc, $item ) use ( $all_plugins ) { $plugin = $all_plugins[ $item ] ?? null; $plugin_id = $plugin['TextDomain'] ?? dirname( $item, 2 ); $plugin_name = $plugin['Name'] ?? $plugin_id; @@ -465,7 +473,7 @@ class Checkout extends AbstractBlock { $payment_gateways = WC()->payment_gateways->payment_gateways(); return array_filter( $payment_gateways, - function( $payment_gateway ) { + function ( $payment_gateway ) { return 'yes' === $payment_gateway->enabled; } ); @@ -500,7 +508,7 @@ class Checkout extends AbstractBlock { $payment_methods[ $payment_method_group ] = array_values( array_filter( $saved_payment_methods, - function( $saved_payment_method ) use ( $payment_gateways ) { + function ( $saved_payment_method ) use ( $payment_gateways ) { return in_array( $saved_payment_method['method']['gateway'], array_keys( $payment_gateways ), true ); } ) diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ComingSoon.php b/plugins/woocommerce/src/Blocks/BlockTypes/ComingSoon.php index 6304507f161..a82be2587ee 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ComingSoon.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ComingSoon.php @@ -21,12 +21,24 @@ class ComingSoon extends AbstractBlock { } /** - * Get the frontend style handle for this block type. + * Enqueue frontend assets for this block, just in time for rendering. * - * @return null + * @internal This prevents the block script being enqueued on all pages. It is only enqueued as needed. Note that + * we intentionally do not pass 'script' to register_block_type. + * + * @param array $attributes Any attributes that currently are available from the block. + * @param string $content The block content. + * @param WP_Block $block The block object. */ - protected function get_block_type_style() { - return null; + protected function enqueue_assets( array $attributes, $content, $block ) { + parent::enqueue_assets( $attributes, $content, $block ); + + if ( isset( $attributes['color'] ) ) { + wp_add_inline_style( + 'wc-blocks-style', + ':root{--woocommerce-coming-soon-color: ' . esc_html( $attributes['color'] ) . '}' + ); + } } /** diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/CustomerAccount.php b/plugins/woocommerce/src/Blocks/BlockTypes/CustomerAccount.php index dee420b76e7..80bd799b224 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/CustomerAccount.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/CustomerAccount.php @@ -34,6 +34,7 @@ class CustomerAccount extends AbstractBlock { 'anchor' => 'core/navigation', 'area' => 'header', 'callback' => 'should_unhook_block', + 'version' => '8.4.0', ), ); @@ -118,19 +119,27 @@ class CustomerAccount extends AbstractBlock { $account_link = get_option( 'woocommerce_myaccount_page_id' ) ? wc_get_account_endpoint_url( 'dashboard' ) : wp_login_url(); $allowed_svg = array( - 'svg' => array( + 'svg' => array( 'class' => true, 'xmlns' => true, 'width' => true, 'height' => true, 'viewbox' => true, ), - 'path' => array( + 'path' => array( 'd' => true, 'fill' => true, 'fill-rule' => true, 'clip-rule' => true, ), + 'circle' => array( + 'cx' => true, + 'cy' => true, + 'r' => true, + 'stroke' => true, + 'stroke-width' => true, + 'fill' => true, + ), ); // Only provide aria-label if the display style is icon only. @@ -158,14 +167,21 @@ class CustomerAccount extends AbstractBlock { } if ( self::DISPLAY_LINE === $attributes['iconStyle'] ) { - return ' - + return ' + + '; } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php index e3021511405..cb5952d2d84 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php @@ -65,6 +65,7 @@ class MiniCart extends AbstractBlock { 'position' => 'after', 'anchor' => 'core/navigation', 'area' => 'header', + 'version' => '8.4.0', ), ); diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Status.php b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Status.php index e2c96926fc7..6cc50f68d3a 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Status.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Status.php @@ -40,14 +40,24 @@ class Status extends AbstractOrderConfirmationBlock { return ''; } - $additional_content = $this->render_account_notice( $order ) . $this->render_confirmation_notice( $order ); + $account_notice = $this->render_account_notice( $order ); + + if ( $account_notice ) { + $block = sprintf( + '
%2$s
', + esc_attr( trim( $classname ) ), + $account_notice + ) . $block; + } + + $additional_content = $this->render_confirmation_notice( $order ); if ( $additional_content ) { - return sprintf( + $block = $block . sprintf( '
%2$s
', esc_attr( trim( $classname ) ), $additional_content - ) . $block; + ); } return $block; @@ -154,9 +164,10 @@ class Status extends AbstractOrderConfirmationBlock { */ protected function render_account_notice( $order = null ) { if ( $order && $order->get_customer_id() && 'store-api' === $order->get_created_via() ) { - $nag = get_user_option( 'default_password_nag', $order->get_customer_id() ); + $nag = get_user_option( 'default_password_nag', $order->get_customer_id() ); + $generate = filter_var( get_option( 'woocommerce_registration_generate_password', false ), FILTER_VALIDATE_BOOLEAN ); - if ( $nag ) { + if ( $nag && $generate ) { return wc_print_notice( sprintf( // translators: %s: site name. diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Summary.php b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Summary.php index e36c1d77f74..3560292ab02 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Summary.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Summary.php @@ -29,11 +29,11 @@ class Summary extends AbstractOrderConfirmationBlock { } $content = '
    '; - $content .= $this->render_summary_row( __( 'Order number:', 'woocommerce' ), $order->get_order_number() ); + $content .= $this->render_summary_row( __( 'Order #:', 'woocommerce' ), $order->get_order_number() ); $content .= $this->render_summary_row( __( 'Date:', 'woocommerce' ), wc_format_datetime( $order->get_date_created() ) ); $content .= $this->render_summary_row( __( 'Total:', 'woocommerce' ), $order->get_formatted_order_total() ); $content .= $this->render_summary_row( __( 'Email:', 'woocommerce' ), $order->get_billing_email() ); - $content .= $this->render_summary_row( __( 'Payment method:', 'woocommerce' ), $order->get_payment_method_title() ); + $content .= $this->render_summary_row( __( 'Payment:', 'woocommerce' ), $order->get_payment_method_title() ); $content .= '
'; return $content; diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php index 77c03b2fd4c..87430430876 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php @@ -167,8 +167,8 @@ class ProductButton extends AbstractBlock { ); $div_directives = ' - data-wc-interactive=\'' . wp_json_encode( $interactive, JSON_NUMERIC_CHECK ) . '\' - data-wc-context=\'' . wp_json_encode( $context, JSON_NUMERIC_CHECK ) . '\' + data-wc-interactive=\'' . wp_json_encode( $interactive, JSON_NUMERIC_CHECK | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) . '\' + data-wc-context=\'' . wp_json_encode( $context, JSON_NUMERIC_CHECK | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) . '\' '; $button_directives = ' diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 9d828541d02..bf42d344a52 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -156,56 +156,102 @@ class ProductCollection extends AbstractBlock { } /** - * Enhances the Product Collection block with client-side pagination. + * Check if next tag is a PC block. * - * This function identifies Product Collection blocks and adds necessary data attributes - * to enable client-side navigation and animation effects. It also enqueues the Interactivity API runtime. + * @param WP_HTML_Tag_processor $p Initial tag processor. + * + * @return bool Answer if PC block is available. + */ + private function is_next_tag_product_collection( $p ) { + return $p->next_tag( array( 'class_name' => 'wp-block-woocommerce-product-collection' ) ); + } + + /** + * Set PC block namespace for Interactivity API. + * + * @param WP_HTML_Tag_processor $p Initial tag processor. + */ + private function set_product_collection_namespace( $p ) { + $p->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-collection' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ); + } + + /** + * Attach the init directive to Product Collection block to call + * the onRender callback. * * @param string $block_content The HTML content of the block. - * @param array $block Block details, including its attributes. + * @param string $collection Collection type. * - * @return string Updated block content with added interactivity attributes. + * @return string Updated HTML content. */ - public function enhance_product_collection_with_interactivity( $block_content, $block ) { - $is_product_collection_block = $block['attrs']['query']['isProductCollectionBlock'] ?? false; - $is_enhanced_pagination_enabled = ! ( $block['attrs']['forcePageReload'] ?? false ); - if ( $is_product_collection_block && $is_enhanced_pagination_enabled ) { - // Enqueue the Interactivity API runtime. - wp_enqueue_script( 'wc-interactivity' ); + private function add_rendering_callback( $block_content, $collection ) { + $p = new \WP_HTML_Tag_Processor( $block_content ); - $p = new \WP_HTML_Tag_Processor( $block_content ); - - // Add `data-wc-navigation-id to the product collection block. - if ( $p->next_tag( array( 'class_name' => 'wp-block-woocommerce-product-collection' ) ) ) { - $p->set_attribute( - 'data-wc-navigation-id', - 'wc-product-collection-' . $this->parsed_block['attrs']['queryId'] - ); - $p->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-collection' ) ) ); + // Add `data-init to the product collection block so we trigger JS event on render. + if ( $this->is_next_tag_product_collection( $p ) ) { + $p->set_attribute( + 'data-wc-init', + 'callbacks.onRender' + ); + if ( $collection ) { $p->set_attribute( 'data-wc-context', wp_json_encode( array( - // The message to be announced by the screen reader when the page is loading or loaded. - 'accessibilityLoadingMessage' => __( 'Loading page, please wait.', 'woocommerce' ), - 'accessibilityLoadedMessage' => __( 'Page Loaded.', 'woocommerce' ), - // We don't prefetch the links if user haven't clicked on pagination links yet. - // This way we avoid prefetching when the page loads. - 'isPrefetchNextOrPreviousLink' => false, + 'collection' => $collection, ), - JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ); - $block_content = $p->get_updated_html(); } + } - /** - * Add two div's: - * 1. Pagination animation for visual users. - * 2. Accessibility div for screen readers, to announce page load states. - */ - $last_tag_position = strripos( $block_content, '
' ); - $accessibility_and_animation_html = ' + return $p->get_updated_html(); + } + + /** + * Attach all the Interactivity API directives responsible + * for client-side navigation. + * + * @param string $block_content The HTML content of the block. + * + * @return string Updated HTML content. + */ + private function enable_client_side_navigation( $block_content ) { + $p = new \WP_HTML_Tag_Processor( $block_content ); + + // Add `data-wc-navigation-id to the product collection block. + if ( $this->is_next_tag_product_collection( $p ) ) { + $p->set_attribute( + 'data-wc-navigation-id', + 'wc-product-collection-' . $this->parsed_block['attrs']['queryId'] + ); + $current_context = json_decode( $p->get_attribute( 'data-wc-context' ) ?? '{}', true ); + $p->set_attribute( + 'data-wc-context', + wp_json_encode( + array( + ...$current_context, + // The message to be announced by the screen reader when the page is loading or loaded. + 'accessibilityLoadingMessage' => __( 'Loading page, please wait.', 'woocommerce' ), + 'accessibilityLoadedMessage' => __( 'Page Loaded.', 'woocommerce' ), + // We don't prefetch the links if user haven't clicked on pagination links yet. + // This way we avoid prefetching when the page loads. + 'isPrefetchNextOrPreviousLink' => false, + ), + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP + ) + ); + $block_content = $p->get_updated_html(); + } + + /** + * Add two div's: + * 1. Pagination animation for visual users. + * 2. Accessibility div for screen readers, to announce page load states. + */ + $last_tag_position = strripos( $block_content, '
' ); + $accessibility_and_animation_html = '
'; - $block_content = substr_replace( - $block_content, - $accessibility_and_animation_html, - $last_tag_position, - 0 - ); + return substr_replace( + $block_content, + $accessibility_and_animation_html, + $last_tag_position, + 0 + ); + } + + /** + * Enhances the Product Collection block with client-side pagination. + * + * This function identifies Product Collection blocks and adds necessary data attributes + * to enable client-side navigation and animation effects. It also enqueues the Interactivity API runtime. + * + * @param string $block_content The HTML content of the block. + * @param array $block Block details, including its attributes. + * + * @return string Updated block content with added interactivity attributes. + */ + public function enhance_product_collection_with_interactivity( $block_content, $block ) { + $is_product_collection_block = $block['attrs']['query']['isProductCollectionBlock'] ?? false; + + if ( $is_product_collection_block ) { + // Enqueue the Interactivity API runtime and set the namespace. + wp_enqueue_script( 'wc-interactivity' ); + $p = new \WP_HTML_Tag_Processor( $block_content ); + if ( $this->is_next_tag_product_collection( $p ) ) { + $this->set_product_collection_namespace( $p ); + } + $block_content = $p->get_updated_html(); + + $collection = $block['attrs']['collection'] ?? ''; + $block_content = $this->add_rendering_callback( $block_content, $collection ); + + $is_enhanced_pagination_enabled = ! ( $block['attrs']['forcePageReload'] ?? false ); + if ( $is_enhanced_pagination_enabled ) { + $block_content = $this->enable_client_side_navigation( $block_content ); + } } return $block_content; @@ -246,7 +324,7 @@ class ProductCollection extends AbstractBlock { $is_enhanced_pagination_enabled = ! ( $this->parsed_block['attrs']['forcePageReload'] ?? false ); // Only proceed if the block is a product collection block, - // enhaced pagination is enabled and query IDs match. + // enhanced pagination is enabled and query IDs match. if ( $is_product_collection_block && $is_enhanced_pagination_enabled && $query_id === $parsed_query_id ) { $block_content = $this->process_pagination_links( $block_content ); } @@ -295,7 +373,7 @@ class ProductCollection extends AbstractBlock { 'class_name' => $class_name, ) ) ) { - $processor->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-collection' ) ) ); + $this->set_product_collection_namespace( $processor ); $processor->set_attribute( 'data-wc-on--click', 'actions.navigate' ); $processor->set_attribute( 'data-wc-key', $key_prefix . '--' . esc_attr( wp_rand() ) ); @@ -314,9 +392,11 @@ class ProductCollection extends AbstractBlock { */ private function is_block_compatible( $block_name ) { // Check for explicitly unsupported blocks. - if ( 'core/post-content' === $block_name || + if ( + 'core/post-content' === $block_name || 'woocommerce/mini-cart' === $block_name || - 'woocommerce/featured-product' === $block_name ) { + 'woocommerce/featured-product' === $block_name + ) { return false; } @@ -334,8 +414,8 @@ class ProductCollection extends AbstractBlock { /** * Check inner blocks of Product Collection block if there's one - * incompatible with Interactivity API and if so, disable client-side - * naviagtion. + * incompatible with the Interactivity API and if so, disable client-side + * navigation. * * @param array $parsed_block The block being rendered. * @return string Returns the parsed block, unmodified. @@ -524,7 +604,10 @@ class ProductCollection extends AbstractBlock { // phpcs:ignore WordPress.DB.SlowDBQuery $block_context_query['tax_query'] = ! empty( $query['tax_query'] ) ? $query['tax_query'] : array(); - $is_exclude_applied_filters = ! ( $block->context['query']['inherit'] ?? false ); + $inherit = $block->context['query']['inherit'] ?? false; + $filterable = $block->context['query']['filterable'] ?? false; + + $is_exclude_applied_filters = ! ( $inherit || $filterable ); return $this->get_final_frontend_query( $block_context_query, $page, $is_exclude_applied_filters ); } @@ -784,7 +867,7 @@ class ProductCollection extends AbstractBlock { * - For array items with numeric keys, we merge them as normal. * - For array items with string keys: * - * - If the value isn't array, we'll use the value comming from the merge array. + * - If the value isn't array, we'll use the value coming from the merge array. * $base = ['orderby' => 'date'] * $new = ['orderby' => 'meta_value_num'] * Result: ['orderby' => 'meta_value_num'] @@ -832,7 +915,7 @@ class ProductCollection extends AbstractBlock { if ( ! isset( $base[ $key ] ) ) { $base[ $key ] = array(); } - $base[ $key ] = $this->array_merge_recursive_replace_non_array_properties( $base[ $key ], $value ); + $base[ $key ] = $this->array_merge_recursive_replace_non_array_properties( $base[ $key ], $value ); } else { $base[ $key ] = $value; } @@ -1089,7 +1172,7 @@ class ProductCollection extends AbstractBlock { $max_price_query = empty( $max_price ) ? array() : array( 'key' => '_price', 'value' => $max_price, - 'compare' => '<', + 'compare' => '<=', 'type' => 'numeric', ); diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductDetails.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductDetails.php index c512966d6ad..907d198dde9 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductDetails.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductDetails.php @@ -1,6 +1,7 @@ render_tabs(); + if ( $hide_tab_title ) { + remove_filter( 'woocommerce_product_description_heading', '__return_empty_string' ); + remove_filter( 'woocommerce_product_additional_information_heading', '__return_empty_string' ); + remove_filter( 'woocommerce_reviews_title', '__return_empty_string' ); + + // Remove the first `h2` of every `.wc-tab`. This is required for the Reviews tabs when there are no reviews and for plugin tabs. + $tabs_html = new WP_HTML_Tag_Processor( $tabs ); + while ( $tabs_html->next_tag( array( 'class_name' => 'wc-tab' ) ) ) { + if ( $tabs_html->next_tag( 'h2' ) ) { + $tabs_html->set_attribute( 'hidden', 'true' ); + } + } + $tabs = $tabs_html->get_updated_html(); + } + $classname = $attributes['className'] ?? ''; $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes ); diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilter.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilter.php index 0b24b3b07d7..6cfc9a1465a 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilter.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilter.php @@ -115,8 +115,8 @@ final class ProductFilter extends AbstractBlock { } $attributes_data = array( - 'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ) ), - 'data-wc-context' => wp_json_encode( array( 'hasSelectedFilter' => $has_selected_filter ) ), + 'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), + 'data-wc-context' => wp_json_encode( array( 'hasSelectedFilter' => $has_selected_filter ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), 'class' => 'wc-block-product-filters', ); diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php index 722e0d0ebaf..679227f3a09 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php @@ -55,8 +55,8 @@ final class ProductFilterActive extends AbstractBlock { $wrapper_attributes = get_block_wrapper_attributes( array( - 'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ) ), - 'data-wc-context' => wp_json_encode( $context ), + 'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), + 'data-wc-context' => wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), 'data-has-filter' => empty( $active_filters ) ? 'no' : 'yes', ) ); @@ -185,7 +185,7 @@ final class ProductFilterActive extends AbstractBlock { private function get_html_attributes( $attributes ) { return array_reduce( array_keys( $attributes ), - function( $acc, $key ) use ( $attributes ) { + function ( $acc, $key ) use ( $attributes ) { $acc .= sprintf( ' %1$s="%2$s"', esc_attr( $key ), esc_attr( $attributes[ $key ] ) ); return $acc; }, @@ -227,7 +227,7 @@ final class ProductFilterActive extends AbstractBlock { return array_filter( $url_query_params, - function( $key ) use ( $filter_param_keys ) { + function ( $key ) use ( $filter_param_keys ) { return in_array( $key, $filter_param_keys, true ); }, ARRAY_FILTER_USE_KEY diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php index ed4bb640c5d..d997799c772 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php @@ -30,6 +30,34 @@ final class ProductFilterAttribute extends AbstractBlock { add_filter( 'collection_filter_query_param_keys', array( $this, 'get_filter_query_param_keys' ), 10, 2 ); add_filter( 'collection_active_filters_data', array( $this, 'register_active_filters_data' ), 10, 2 ); + add_action( 'deleted_transient', array( $this, 'delete_default_attribute_id_transient' ) ); + add_action( 'wp_loaded', array( $this, 'register_block_patterns' ) ); + } + + /** + * Extra data passed through from server to client for block. + * + * @param array $attributes Any attributes that currently are available from the block. + * Note, this will be empty in the editor context when the block is + * not in the post content on editor load. + */ + protected function enqueue_data( array $attributes = array() ) { + parent::enqueue_data( $attributes ); + + if ( is_admin() ) { + $this->asset_data_registry->add( 'defaultProductFilterAttribute', $this->get_default_product_attribute() ); + } + } + + /** + * Delete the default attribute id transient when the attribute taxonomies are deleted. + * + * @param string $transient The transient name. + */ + public function delete_default_attribute_id_transient( $transient ) { + if ( 'wc_attribute_taxonomies' === $transient ) { + delete_transient( 'wc_block_product_filter_attribute_default_attribute' ); + } } /** @@ -43,7 +71,7 @@ final class ProductFilterAttribute extends AbstractBlock { public function get_filter_query_param_keys( $filter_param_keys, $url_param_keys ) { $attribute_param_keys = array_filter( $url_param_keys, - function( $param ) { + function ( $param ) { return strpos( $param, 'filter_' ) === 0 || strpos( $param, 'query_type_' ) === 0; } ); @@ -64,7 +92,7 @@ final class ProductFilterAttribute extends AbstractBlock { public function register_active_filters_data( $data, $params ) { $product_attributes_map = array_reduce( wc_get_attribute_taxonomies(), - function( $acc, $attribute_object ) { + function ( $acc, $attribute_object ) { $acc[ $attribute_object->attribute_name ] = $attribute_object->attribute_label; return $acc; }, @@ -73,7 +101,7 @@ final class ProductFilterAttribute extends AbstractBlock { $active_product_attributes = array_reduce( array_keys( $params ), - function( $acc, $attribute ) { + function ( $acc, $attribute ) { if ( strpos( $attribute, 'filter_' ) === 0 ) { $acc[] = str_replace( 'filter_', '', $attribute ); } @@ -84,7 +112,7 @@ final class ProductFilterAttribute extends AbstractBlock { $active_product_attributes = array_filter( $active_product_attributes, - function( $item ) use ( $product_attributes_map ) { + function ( $item ) use ( $product_attributes_map ) { return in_array( $item, array_keys( $product_attributes_map ), true ); } ); @@ -96,7 +124,7 @@ final class ProductFilterAttribute extends AbstractBlock { // Get attribute term by slug. $terms = array_map( - function( $term ) use ( $product_attribute, $action_namespace ) { + function ( $term ) use ( $product_attribute, $action_namespace ) { $term_object = get_term_by( 'slug', $term, "pa_{$product_attribute}" ); return array( 'title' => $term_object->name, @@ -107,7 +135,8 @@ final class ProductFilterAttribute extends AbstractBlock { 'value' => $term, 'attributeSlug' => $product_attribute, 'queryType' => get_query_var( "query_type_{$product_attribute}" ), - ) + ), + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), ), ); @@ -133,6 +162,11 @@ final class ProductFilterAttribute extends AbstractBlock { * @return string Rendered block type output. */ protected function render( $attributes, $content, $block ) { + if ( empty( $attributes['attributeId'] ) ) { + $default_product_attribute = $this->get_default_product_attribute(); + $attributes['attributeId'] = $default_product_attribute->attribute_id; + } + // don't render if its admin, or ajax in progress. if ( is_admin() || wp_doing_ajax() || empty( $attributes['attributeId'] ) ) { return ''; @@ -146,7 +180,7 @@ final class ProductFilterAttribute extends AbstractBlock { '
', get_block_wrapper_attributes( array( - 'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ) ), + 'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), 'data-has-filter' => 'no', ) ), @@ -168,7 +202,7 @@ final class ProductFilterAttribute extends AbstractBlock { ); $attribute_options = array_map( - function( $term ) use ( $attribute_counts, $selected_terms ) { + function ( $term ) use ( $attribute_counts, $selected_terms ) { $term = (array) $term; $term['count'] = $attribute_counts[ $term['term_id'] ]; $term['selected'] = in_array( $term['slug'], $selected_terms, true ); @@ -179,7 +213,7 @@ final class ProductFilterAttribute extends AbstractBlock { $filtered_options = array_filter( $attribute_options, - function( $option ) { + function ( $option ) { return $option['count'] > 0; } ); @@ -191,15 +225,15 @@ final class ProductFilterAttribute extends AbstractBlock { $context = array( 'attributeSlug' => str_replace( 'pa_', '', $product_attribute->slug ), 'queryType' => $attributes['queryType'], - 'selectType' => $attributes['selectType'], + 'selectType' => 'multiple', ); return sprintf( '
%2$s%3$s
', get_block_wrapper_attributes( array( - 'data-wc-context' => wp_json_encode( $context ), - 'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ) ), + 'data-wc-context' => wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), + 'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), 'data-has-filter' => 'yes', ) ), @@ -242,7 +276,7 @@ final class ProductFilterAttribute extends AbstractBlock { 'items' => $list_items, 'action' => "{$this->get_full_block_name()}::actions.navigate", 'selected_items' => $selected_items, - 'select_type' => $attributes['selectType'] ?? 'multiple', + 'select_type' => 'multiple', // translators: %s is a product attribute name. 'placeholder' => sprintf( __( 'Select %s', 'woocommerce' ), $product_attribute->name ), ) @@ -264,7 +298,7 @@ final class ProductFilterAttribute extends AbstractBlock { $show_counts = $attributes['showCounts'] ?? false; $list_options = array_map( - function( $option ) use ( $show_counts ) { + function ( $option ) use ( $show_counts ) { return array( 'id' => $option['slug'] . '-' . $option['term_id'], 'checked' => $option['selected'], @@ -320,13 +354,132 @@ final class ProductFilterAttribute extends AbstractBlock { $attribute_counts = array_reduce( $attribute_counts, - function( $acc, $count ) { + function ( $acc, $count ) { $acc[ $count['term'] ] = $count['count']; return $acc; }, - [] + array() ); return $attribute_counts; } + + /** + * Get the attribute if with most term but closest to 30 terms. + * + * @return object + */ + private function get_default_product_attribute() { + // Cache this variable in memory to prevent repeated database queries to check + // for transient in the same request. + static $cached = null; + + if ( $cached ) { + return $cached; + } + + $cached = get_transient( 'wc_block_product_filter_attribute_default_attribute' ); + + if ( $cached ) { + return $cached; + } + + $attributes = wc_get_attribute_taxonomies(); + + $attributes_count = array_map( + function ( $attribute ) { + return intval( + wp_count_terms( + array( + 'taxonomy' => 'pa_' . $attribute->attribute_name, + 'hide_empty' => false, + ) + ) + ); + }, + $attributes + ); + + asort( $attributes_count ); + + $search = 30; + $closest = null; + $attribute_id = null; + + foreach ( $attributes_count as $id => $count ) { + if ( null === $closest || abs( $search - $closest ) > abs( $count - $search ) ) { + $closest = $count; + $attribute_id = $id; + } + + if ( $closest && $count >= $search ) { + break; + } + } + + $default_attribute = (object) array( + 'attribute_id' => '0', + 'attribute_name' => 'attribute', + 'attribute_label' => __( 'Attribute', 'woocommerce' ), + 'attribute_type' => 'select', + 'attribute_orderby' => 'menu_order', + 'attribute_public' => 0, + ); + + if ( $attribute_id ) { + $default_attribute = $attributes[ $attribute_id ]; + } + + set_transient( 'wc_block_product_filter_attribute_default_attribute', $default_attribute ); + + return $default_attribute; + } + + /** + * Register pattern for default product attribute. + */ + public function register_block_patterns() { + $default_attribute = $this->get_default_product_attribute(); + register_block_pattern( + 'woocommerce/default-attribute-filter', + array( + 'title' => '', + 'inserter' => false, + 'content' => strtr( + ' + + +
+ +

{{attribute_label}}

+ + + + +
+ +
+ Clear +
+ +
+ + +
+ + + + + ', + array( + '{{attribute_id}}' => intval( $default_attribute->attribute_id ), + '{{attribute_label}}' => esc_html( $default_attribute->attribute_label ), + ) + ), + ) + ); + } } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php index 80a0ad50a7f..97b73763b65 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php @@ -44,7 +44,7 @@ final class ProductFilterPrice extends AbstractBlock { public function get_filter_query_param_keys( $filter_param_keys, $url_param_keys ) { $price_param_keys = array_filter( $url_param_keys, - function( $param ) { + function ( $param ) { return self::MIN_PRICE_QUERY_VAR === $param || self::MAX_PRICE_QUERY_VAR === $param; } ); @@ -141,8 +141,13 @@ final class ProductFilterPrice extends AbstractBlock { ) = $attributes; $wrapper_attributes = array( - 'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ) ), - 'data-wc-context' => wp_json_encode( $data ), + 'data-wc-interactive' => wp_json_encode( + array( + 'namespace' => $this->get_full_block_name(), + ), + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP, + ), + 'data-wc-context' => wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), 'data-has-filter' => 'no', ); @@ -166,7 +171,8 @@ final class ProductFilterPrice extends AbstractBlock { type="text" value="%s" data-wc-bind--value="state.formattedMinPrice" - data-wc-on--change="actions.updateProducts" + data-wc-on--input="actions.updateProducts" + data-wc-on--focus="actions.selectInputContent" pattern="" />', wp_strip_all_tags( $formatted_min_price ) @@ -184,7 +190,8 @@ final class ProductFilterPrice extends AbstractBlock { type="text" value="%s" data-wc-bind--value="state.formattedMaxPrice" - data-wc-on--change="actions.updateProducts" + data-wc-on--input="actions.updateProducts" + data-wc-on--focus="actions.selectInputContent" />', wp_strip_all_tags( $formatted_max_price ) ) : sprintf( @@ -227,6 +234,7 @@ final class ProductFilterPrice extends AbstractBlock { data-wc-bind--max="context.maxRange" data-wc-bind--value="context.minPrice" data-wc-on--change="actions.updateProducts" + data-wc-on--input="actions.updateRange" >
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php index 8299a6028f1..76bcad3727d 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php @@ -47,7 +47,7 @@ final class ProductFilterRating extends AbstractBlock { public function get_filter_query_param_keys( $filter_param_keys, $url_param_keys ) { $rating_param_keys = array_filter( $url_param_keys, - function( $param ) { + function ( $param ) { return self::RATING_FILTER_QUERY_VAR === $param; } ); @@ -84,8 +84,8 @@ final class ProductFilterRating extends AbstractBlock { /* translators: %d is the rating value. */ 'title' => sprintf( __( 'Rated %d out of 5', 'woocommerce' ), $rating ), 'attributes' => array( - 'data-wc-on--click' => "{$this->get_full_block_name()}::actions.removeFilter", - 'data-wc-context' => "{$this->get_full_block_name()}::" . wp_json_encode( array( 'value' => $rating ) ), + 'data-wc-on--click' => esc_attr( "{$this->get_full_block_name()}::actions.removeFilter" ), + 'data-wc-context' => esc_attr( "{$this->get_full_block_name()}::" ) . wp_json_encode( array( 'value' => $rating ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), ), ); }, diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStockStatus.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStockStatus.php index 6c58b042a72..ae9295801e9 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStockStatus.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStockStatus.php @@ -45,7 +45,7 @@ final class ProductFilterStockStatus extends AbstractBlock { public function get_filter_query_param_keys( $filter_param_keys, $url_param_keys ) { $stock_param_keys = array_filter( $url_param_keys, - function( $param ) { + function ( $param ) { return self::STOCK_STATUS_QUERY_VAR === $param; } ); @@ -81,12 +81,12 @@ final class ProductFilterStockStatus extends AbstractBlock { $action_namespace = $this->get_full_block_name(); $active_stock_statuses = array_map( - function( $status ) use ( $stock_status_options, $action_namespace ) { + function ( $status ) use ( $stock_status_options, $action_namespace ) { return array( 'title' => $stock_status_options[ $status ], 'attributes' => array( 'data-wc-on--click' => "$action_namespace::actions.removeFilter", - 'data-wc-context' => "$action_namespace::" . wp_json_encode( array( 'value' => $status ) ), + 'data-wc-context' => "$action_namespace::" . wp_json_encode( array( 'value' => $status ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), ), ); }, @@ -168,7 +168,7 @@ final class ProductFilterStockStatus extends AbstractBlock { $list_items = array_values( array_map( - function( $item ) use ( $stock_statuses, $show_counts, $selected_stock_statuses ) { + function ( $item ) use ( $stock_statuses, $show_counts, $selected_stock_statuses ) { $label = $show_counts ? $stock_statuses[ $item['status'] ] . ' (' . $item['count'] . ')' : $stock_statuses[ $item['status'] ]; return array( 'label' => $label, @@ -183,13 +183,13 @@ final class ProductFilterStockStatus extends AbstractBlock { $selected_items = array_values( array_filter( $list_items, - function( $item ) use ( $selected_stock_statuses ) { + function ( $item ) use ( $selected_stock_statuses ) { return in_array( $item['value'], $selected_stock_statuses, true ); } ) ); - $data_directive = wp_json_encode( array( 'namespace' => $this->get_full_block_name() ) ); + $data_directive = wp_json_encode( array( 'namespace' => $this->get_full_block_name() ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ); ob_start(); ?> @@ -255,7 +255,7 @@ final class ProductFilterStockStatus extends AbstractBlock { return array_filter( $data, - function( $stock_count ) { + function ( $stock_count ) { return $stock_count['count'] > 0; } ); diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFiltersOverlay.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFiltersOverlay.php index 0d14d0ef60e..449bc4f8c6c 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFiltersOverlay.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFiltersOverlay.php @@ -1,6 +1,9 @@ %s
', esc_html__( 'Filters Overlay', 'woocommerce' ) ); - $html = ob_get_clean(); + return $content; + } - return $html; + /** + * Extra data passed through from server to client for block. + * + * @param array $attributes Any attributes that currently are available from the block. + * Note, this will be empty in the editor context when the block is + * not in the post content on editor load. + */ + protected function enqueue_data( array $attributes = [] ) { + parent::enqueue_data( $attributes ); + + $template_part_edit_uri = ''; + + if ( + current_user_can( 'edit_theme_options' ) && + ( wc_current_theme_is_fse_theme() || current_theme_supports( 'block-template-parts' ) ) + ) { + $theme_slug = BlockTemplateUtils::theme_has_template_part( 'product-filters-overlay' ) ? wp_get_theme()->get_stylesheet() : BlockTemplateUtils::PLUGIN_SLUG; + + $site_editor_uri = add_query_arg( + array( + 'canvas' => 'edit', + 'path' => '/template-parts/single', + ), + admin_url( 'site-editor.php' ) + ); + + $template_part_edit_uri = esc_url_raw( + add_query_arg( + array( + 'postId' => sprintf( '%s//%s', $theme_slug, 'product-filters-overlay' ), + 'postType' => 'wp_template_part', + ), + $site_editor_uri + ) + ); + } + + $this->asset_data_registry->add( + 'templatePartProductFiltersOverlayEditUri', + $template_part_edit_uri + ); } } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFiltersOverlayNavigation.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFiltersOverlayNavigation.php index 353163220be..0eb6cfe11d6 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFiltersOverlayNavigation.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFiltersOverlayNavigation.php @@ -11,4 +11,87 @@ class ProductFiltersOverlayNavigation extends AbstractBlock { * @var string */ protected $block_name = 'product-filters-overlay-navigation'; + + /** + * Register the context + * + * @return string[] + */ + protected function get_block_type_uses_context() { + return [ 'woocommerce/product-filters/overlay' ]; + } + + /** + * Get the frontend script handle for this block type. + * + * @see $this->register_block_type() + * @param string $key Data to get, or default to everything. + * @return array|string|null + */ + protected function get_block_type_script( $key = null ) { + return null; + } + + /** + * Include and render the block. + * + * @param array $attributes Block attributes. Default empty array. + * @param string $content Block content. Default empty string. + * @param WP_Block $block Block instance. + * @return string Rendered block type output. + */ + protected function render( $attributes, $content, $block ) { + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'class' => 'wc-block-product-filters-overlay-navigation', + ) + ); + $overlay_mode = $block->context['woocommerce/product-filters/overlay']; + + if ( 'never' === $overlay_mode || ( ! wp_is_mobile() && 'mobile' === $overlay_mode ) ) { + return null; + } + + $html_content = strtr( + '
+ {{primary_content}} + {{secondary_content}} +
', + array( + '{{wrapper_attributes}}' => $wrapper_attributes, + '{{primary_content}}' => 'open-overlay' === $attributes['triggerType'] ? $this->render_icon( $attributes ) : $this->render_label( $attributes ), + '{{secondary_content}}' => 'open-overlay' === $attributes['triggerType'] ? $this->render_label( $attributes ) : $this->render_icon( $attributes ), + ) + ); + return $html_content; + } + + /** + * Gets the icon to render depending on the triggerType attribute. + * + * @param array $attributes Block attributes. + * + * @return string Label to render on the block + */ + private function render_icon( $attributes ) { + if ( 'open-overlay' === $attributes['triggerType'] ) { + return ''; + } + + return ''; + } + + /** + * Gets the label to render depending on the triggerType. + * + * @param array $attributes Block attributes. + * + * @return string Label to render on the block + */ + private function render_label( $attributes ) { + return sprintf( + '%s', + 'open-overlay' === $attributes['triggerType'] ? __( 'Filters', 'woocommerce' ) : __( 'Close', 'woocommerce' ) + ); + } } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php index 18ccb0c8fa7..812a3baaf98 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php @@ -59,7 +59,7 @@ class ProductGallery extends AbstractBlock { $html = array_reduce( $parsed_template, - function( $carry, $item ) { + function ( $carry, $item ) { return $carry . render_block( $item ); }, '' @@ -134,7 +134,7 @@ class ProductGallery extends AbstractBlock { $p = new \WP_HTML_Tag_Processor( $html ); if ( $p->next_tag() ) { - $p->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ) ) ); + $p->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ); $p->set_attribute( 'data-wc-context', wp_json_encode( @@ -147,7 +147,8 @@ class ProductGallery extends AbstractBlock { 'mouseIsOverPreviousOrNextButton' => false, 'productId' => $product_id, 'elementThatTriggeredDialogOpening' => null, - ) + ), + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ); diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php index af79a57ae49..b7a1ea0668e 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php @@ -98,7 +98,7 @@ class ProductGalleryLargeImage extends AbstractBlock { '{content}' => $content, '{directives}' => array_reduce( array_keys( $directives ), - function( $carry, $key ) use ( $directives ) { + function ( $carry, $key ) use ( $directives ) { return $carry . ' ' . $key . '="' . esc_attr( $directives[ $key ] ) . '"'; }, '' @@ -143,7 +143,7 @@ class ProductGalleryLargeImage extends AbstractBlock { ); $main_image_with_wrapper = array_map( - function( $main_image_element ) { + function ( $main_image_element ) { return "'; }, $main_images @@ -151,7 +151,6 @@ class ProductGalleryLargeImage extends AbstractBlock { $visible_main_image = array_shift( $main_images ); return array( $visible_main_image, $main_image_with_wrapper ); - } /** @@ -187,8 +186,8 @@ class ProductGalleryLargeImage extends AbstractBlock { ); return array( - 'data-wc-interactive' => wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ) ), - 'data-wc-context' => wp_json_encode( $context, JSON_NUMERIC_CHECK ), + 'data-wc-interactive' => wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), + 'data-wc-context' => wp_json_encode( $context, JSON_NUMERIC_CHECK | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), 'data-wc-on--mousemove' => 'actions.startZoom', 'data-wc-on--mouseleave' => 'actions.resetZoom', ); diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImageNextPrevious.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImageNextPrevious.php index 9b7a907321a..2804ba23114 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImageNextPrevious.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImageNextPrevious.php @@ -74,6 +74,10 @@ class ProductGalleryLargeImageNextPrevious extends AbstractBlock { $product = wc_get_product( $post_id ); + if ( ! $product instanceof \WC_Product ) { + return ''; + } + $product_gallery = $product->get_gallery_image_ids(); if ( empty( $product_gallery ) ) { @@ -129,7 +133,7 @@ class ProductGalleryLargeImageNextPrevious extends AbstractBlock { '{next_button}' => $next_button, '{alignment_class}' => $alignment_class, '{position_class}' => $position_class, - '{data_wc_interactive}' => wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ), JSON_NUMERIC_CHECK ), + '{data_wc_interactive}' => wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ), JSON_NUMERIC_CHECK | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), ) ); } @@ -188,7 +192,6 @@ class ProductGalleryLargeImageNextPrevious extends AbstractBlock { $this->get_class_suffix( $context ), $icon_path ); - } /** @@ -229,6 +232,5 @@ class ProductGalleryLargeImageNextPrevious extends AbstractBlock { $this->get_class_suffix( $context ), $icon_path ); - } } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryPager.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryPager.php index e3bc873cbde..58e268addc0 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryPager.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryPager.php @@ -74,7 +74,7 @@ class ProductGalleryPager extends AbstractBlock {
', $wrapper_attributes, $html, - wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ) ) + wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ); } return ''; @@ -127,7 +127,10 @@ class ProductGalleryPager extends AbstractBlock { $p->set_attribute( 'data-wc-context', wp_json_encode( - array( 'imageId' => strval( $product_gallery_image_id ) ), + array( + 'imageId' => strval( $product_gallery_image_id ), + ), + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP, ) ); $p->set_attribute( diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php index 09b0d8cceee..58f0765923e 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php @@ -168,7 +168,7 @@ class ProductGalleryThumbnails extends AbstractBlock { } } - $thumbnails_count++; + ++$thumbnails_count; } return sprintf( @@ -178,7 +178,7 @@ class ProductGalleryThumbnails extends AbstractBlock { esc_attr( $classes_and_styles['classes'] ), esc_attr( $classes_and_styles['styles'] ), $html, - wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ) ) + wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ); } } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductMeta.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductMeta.php new file mode 100644 index 00000000000..d6884d84514 --- /dev/null +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductMeta.php @@ -0,0 +1,55 @@ + '_price', 'value' => $max_price, - 'compare' => '<', + 'compare' => '<=', 'type' => 'numeric', ]; @@ -778,7 +778,7 @@ class ProductQuery extends AbstractBlock { * - For array items with numeric keys, we merge them as normal. * - For array items with string keys: * - * - If the value isn't array, we'll use the value comming from the merge array. + * - If the value isn't array, we'll use the value coming from the merge array. * $base = ['orderby' => 'date'] * $new = ['orderby' => 'meta_value_num'] * Result: ['orderby' => 'meta_value_num'] diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductRating.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductRating.php index 55e95ccca87..1d729677171 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductRating.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductRating.php @@ -114,7 +114,9 @@ class ProductRating extends AbstractBlock { $post_id = isset( $block->context['postId'] ) ? $block->context['postId'] : ''; $product = wc_get_product( $post_id ); - if ( $product && $product->get_review_count() > 0 ) { + if ( $product && $product->get_review_count() > 0 + && $product->get_reviews_allowed() + && wc_reviews_enabled() ) { $product_reviews_count = $product->get_review_count(); $product_rating = $product->get_average_rating(); $parsed_attributes = $this->parse_attributes( $attributes ); diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductTemplate.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductTemplate.php index cafc22569f9..583f6a464c2 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductTemplate.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductTemplate.php @@ -17,6 +17,18 @@ class ProductTemplate extends AbstractBlock { */ protected $block_name = 'product-template'; + /** + * Initialize this block type. + * + * - Hook into WP lifecycle. + * - Register the block with WordPress. + * - Hook into pre_render_block to update the query. + */ + protected function initialize() { + add_filter( 'block_type_metadata_settings', array( $this, 'add_block_type_metadata_settings' ), 10, 2 ); + parent::initialize(); + } + /** * Get the frontend script handle for this block type. * @@ -134,4 +146,19 @@ class ProductTemplate extends AbstractBlock { return false; } + + /** + * Product Template renders inner blocks manually so we need to skip default + * rendering routine for its inner blocks + * + * @param array $settings Array of determined settings for registering a block type. + * @param array $metadata Metadata provided for registering a block type. + * @return array + */ + public function add_block_type_metadata_settings( $settings, $metadata ) { + if ( ! empty( $metadata['name'] ) && 'woocommerce/product-template' === $metadata['name'] ) { + $settings['skip_inner_blocks'] = true; + } + return $settings; + } } diff --git a/plugins/woocommerce/src/Blocks/BlockTypesController.php b/plugins/woocommerce/src/Blocks/BlockTypesController.php index 9b021bdbbe7..f6d84ecc051 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypesController.php +++ b/plugins/woocommerce/src/Blocks/BlockTypesController.php @@ -227,20 +227,28 @@ final class BlockTypesController { * @return string */ public function add_data_attributes( $content, $block ) { - $block_name = $block['blockName']; - if ( ! $this->block_should_have_data_attributes( $block_name ) ) { + $content = trim( $content ); + + if ( ! $this->block_should_have_data_attributes( $block['blockName'] ) ) { return $content; } - $attributes = (array) $block['attrs']; - $exclude_attributes = array( 'className', 'align' ); - $escaped_data_attributes = array( - 'data-block-name="' . esc_attr( $block['blockName'] ) . '"', - ); + $attributes = (array) $block['attrs']; + $exclude_attributes = array( 'className', 'align' ); - foreach ( $attributes as $key => $value ) { - if ( in_array( $key, $exclude_attributes, true ) ) { + $processor = new \WP_HTML_Tag_Processor( $content ); + + if ( + false === $processor->next_token() || + 'DIV' !== $processor->get_token_name() || + $processor->is_tag_closer() + ) { + return $content; + } + + foreach ( $attributes as $key => $value ) { + if ( ! is_string( $key ) || in_array( $key, $exclude_attributes, true ) ) { continue; } if ( is_bool( $value ) ) { @@ -249,10 +257,16 @@ final class BlockTypesController { if ( ! is_scalar( $value ) ) { $value = wp_json_encode( $value ); } - $escaped_data_attributes[] = 'data-' . esc_attr( strtolower( preg_replace( '/(?set_attribute( "data-{$key}", $value ); } - return preg_replace( '/^
set_attribute( 'data-block-name', $block['blockName'] ); + return $processor->get_updated_html(); } /** @@ -271,7 +285,7 @@ final class BlockTypesController { * and prevent them from showing as an option in the Legacy Widget block. * * @param array $widget_types An array of widgets hidden in core. - * @return array $widget_types An array inluding the WooCommerce widgets to hide. + * @return array $widget_types An array including the WooCommerce widgets to hide. */ public function hide_legacy_widgets_with_block_equivalent( $widget_types ) { array_push( @@ -336,6 +350,7 @@ final class BlockTypesController { 'ProductGalleryThumbnails', 'ProductImage', 'ProductImageGallery', + 'ProductMeta', 'ProductNew', 'ProductOnSale', 'ProductPrice', @@ -423,7 +438,6 @@ final class BlockTypesController { $block_types = array_diff( $block_types, array( - 'AddToCartForm', 'Breadcrumbs', 'CatalogSorting', 'ClassicTemplate', diff --git a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php index 7badad23aa7..dbf929aa3fb 100644 --- a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php +++ b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php @@ -132,18 +132,6 @@ class Bootstrap { $is_store_api_request = wc()->is_store_api_request(); if ( ! $is_store_api_request && ( wc_current_theme_is_fse_theme() || current_theme_supports( 'block-template-parts' ) ) ) { - $this->container->register( - BlockTemplatesRegistry::class, - function () { - return new BlockTemplatesRegistry(); - } - ); - $this->container->register( - BlockTemplatesController::class, - function () { - return new BlockTemplatesController(); - } - ); $this->container->get( BlockTemplatesRegistry::class )->init(); $this->container->get( BlockTemplatesController::class )->init(); } @@ -181,8 +169,6 @@ class Bootstrap { $this->container->get( BlockPatterns::class ); $this->container->get( BlockTypesController::class ); $this->container->get( ClassicTemplatesCompatibility::class ); - $this->container->get( ArchiveProductTemplatesCompatibility::class )->init(); - $this->container->get( SingleProductTemplateCompatibility::class )->init(); $this->container->get( Notices::class )->init(); $this->container->get( PTKPatternsStore::class ); $this->container->get( TemplateOptions::class )->init(); @@ -288,18 +274,6 @@ class Bootstrap { return new ClassicTemplatesCompatibility( $asset_data_registry ); } ); - $this->container->register( - ArchiveProductTemplatesCompatibility::class, - function () { - return new ArchiveProductTemplatesCompatibility(); - } - ); - $this->container->register( - SingleProductTemplateCompatibility::class, - function () { - return new SingleProductTemplateCompatibility(); - } - ); $this->container->register( DraftOrders::class, function ( Container $container ) { @@ -448,6 +422,18 @@ class Bootstrap { return new QueryFilters(); } ); + $this->container->register( + BlockTemplatesRegistry::class, + function () { + return new BlockTemplatesRegistry(); + } + ); + $this->container->register( + BlockTemplatesController::class, + function () { + return new BlockTemplatesController(); + } + ); } /** diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php index 1ea78bcdc53..dcf2e0351f8 100644 --- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php +++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php @@ -386,17 +386,8 @@ class CheckoutFields { $field_data['options'] = $cleaned_options; - // If the field is not required, inject an empty option at the start. - if ( isset( $field_data['required'] ) && false === $field_data['required'] && ! in_array( '', $added_values, true ) ) { - $field_data['options'] = array_merge( - [ - [ - 'value' => '', - 'label' => '', - ], - ], - $field_data['options'] - ); + if ( isset( $field_data['placeholder'] ) ) { + $field_data['placeholder'] = sanitize_text_field( $field_data['placeholder'] ); } return $field_data; @@ -517,6 +508,7 @@ class CheckoutFields { 'hidden' => false, 'autocomplete' => 'email', 'autocapitalize' => 'none', + 'type' => 'email', 'index' => 0, ], 'country' => [ @@ -789,7 +781,7 @@ class CheckoutFields { * @return mixed */ public function update_default_locale_with_fields( $locale ) { - foreach ( $this->fields_locations['address'] as $field_id => $additional_field ) { + foreach ( $this->get_fields_for_location( 'address' ) as $field_id => $additional_field ) { if ( empty( $locale[ $field_id ] ) ) { $locale[ $field_id ] = $additional_field; } diff --git a/plugins/woocommerce/src/Blocks/InteractivityComponents/CheckboxList.php b/plugins/woocommerce/src/Blocks/InteractivityComponents/CheckboxList.php index 647d44a0bb7..8129019d2a0 100644 --- a/plugins/woocommerce/src/Blocks/InteractivityComponents/CheckboxList.php +++ b/plugins/woocommerce/src/Blocks/InteractivityComponents/CheckboxList.php @@ -28,50 +28,82 @@ class CheckboxList { $items = $props['items'] ?? array(); $checkbox_list_context = array( 'items' => $items ); $on_change = $props['on_change'] ?? ''; + $namespace = wp_json_encode( array( 'namespace' => 'woocommerce/interactivity-checkbox-list' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ); - $namespace = wp_json_encode( array( 'namespace' => 'woocommerce/interactivity-checkbox-list' ) ); - + $checked_items = array_filter( + $items, + function ( $item ) { + return $item['checked']; + } + ); + $show_initially = $props['show_initially'] ?? 15; + $remaining_initial_unchecked = count( $checked_items ) > $show_initially ? count( $checked_items ) : $show_initially - count( $checked_items ); + $count = 0; ob_start(); ?> -
-
-
-
    - - -
  • -
    - -
    -
  • - -
-
-
+
+
    + + +
  • = $remaining_initial_unchecked ) : + ?> + class="wc-block-interactivity-components-checkbox-list__item hidden" + data-wc-class--hidden="!context.showAll" + + + + + class="wc-block-interactivity-components-checkbox-list__item" + > + +
  • + +
+ $show_initially ) : ?> + + + +
'woocommerce/interactivity-dropdown' ) ); + $namespace = wp_json_encode( array( 'namespace' => 'woocommerce/interactivity-dropdown' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ); ob_start(); ?>
-
+
@@ -118,7 +118,7 @@ class Dropdown { data-wc-class--is-selected="state.isSelected" class="components-form-token-field__suggestion" data-wc-bind--aria-selected="state.isSelected" - data-wc-context='' + data-wc-context='' >
diff --git a/plugins/woocommerce/src/Blocks/Patterns/PTKPatternsStore.php b/plugins/woocommerce/src/Blocks/Patterns/PTKPatternsStore.php index c5074cea0ef..2b24e1a9657 100644 --- a/plugins/woocommerce/src/Blocks/Patterns/PTKPatternsStore.php +++ b/plugins/woocommerce/src/Blocks/Patterns/PTKPatternsStore.php @@ -91,11 +91,13 @@ class PTKPatternsStore { * @return void */ private function schedule_action_if_not_pending( $action ) { - if ( as_has_scheduled_action( $action ) ) { + $last_request = get_transient( 'last_fetch_patterns_request' ); + if ( as_has_scheduled_action( $action ) || false !== $last_request ) { return; } as_schedule_single_action( time(), $action ); + set_transient( 'last_fetch_patterns_request', time(), HOUR_IN_SECONDS ); } /** @@ -185,6 +187,7 @@ class PTKPatternsStore { $patterns = $this->ptk_client->fetch_patterns( array( + // This is the site where the patterns are stored. Despite the 'wpcomstaging.com' domain suggesting a staging environment, this URL points to the production environment where stable versions of the patterns are maintained. 'site' => 'wooblockpatterns.wpcomstaging.com', 'categories' => array( '_woo_intro', diff --git a/plugins/woocommerce/src/Blocks/QueryFilters.php b/plugins/woocommerce/src/Blocks/QueryFilters.php index 76489d8a8cd..274566be8b0 100644 --- a/plugins/woocommerce/src/Blocks/QueryFilters.php +++ b/plugins/woocommerce/src/Blocks/QueryFilters.php @@ -18,7 +18,7 @@ final class QueryFilters { } /** - * Filter the posts clauses of the main query to suport global filters. + * Filter the posts clauses of the main query to support global filters. * * @param array $args Query args. * @param \WP_Query $wp_query WP_Query object. diff --git a/plugins/woocommerce/src/Blocks/Templates/AbstractPageTemplate.php b/plugins/woocommerce/src/Blocks/Templates/AbstractPageTemplate.php index 2831921e3ec..a455cecb8ee 100644 --- a/plugins/woocommerce/src/Blocks/Templates/AbstractPageTemplate.php +++ b/plugins/woocommerce/src/Blocks/Templates/AbstractPageTemplate.php @@ -14,7 +14,6 @@ abstract class AbstractPageTemplate extends AbstractTemplate { */ public function init() { add_filter( 'page_template_hierarchy', array( $this, 'page_template_hierarchy' ), 1 ); - add_filter( 'pre_get_document_title', array( $this, 'page_template_title' ) ); } /** @@ -48,7 +47,10 @@ abstract class AbstractPageTemplate extends AbstractTemplate { } /** - * Filter the page title when the template is active. + * Forces the page title to match the template title when this template is active. + * + * Only applies when hooked into `pre_get_document_title`. Most templates used for pages will not require this because + * the page title should be used instead. * * @param string $title Page title. * @return string diff --git a/plugins/woocommerce/src/Blocks/Templates/AbstractTemplateCompatibility.php b/plugins/woocommerce/src/Blocks/Templates/AbstractTemplateCompatibility.php index 3d622a345c2..cbd427547b0 100644 --- a/plugins/woocommerce/src/Blocks/Templates/AbstractTemplateCompatibility.php +++ b/plugins/woocommerce/src/Blocks/Templates/AbstractTemplateCompatibility.php @@ -21,10 +21,6 @@ abstract class AbstractTemplateCompatibility { * Initialization method. */ public function init() { - if ( ! wc_current_theme_is_fse_theme() ) { - return; - } - $this->set_hook_data(); add_filter( @@ -61,9 +57,9 @@ abstract class AbstractTemplateCompatibility { * @since 7.6.0 * @param boolean. */ - $is_disabled_compatility_layer = apply_filters( 'woocommerce_disable_compatibility_layer', false ); + $is_disabled_compatibility_layer = apply_filters( 'woocommerce_disable_compatibility_layer', false ); - if ( $is_disabled_compatility_layer ) { + if ( $is_disabled_compatibility_layer ) { return $block_content; } diff --git a/plugins/woocommerce/src/Blocks/Templates/ArchiveProductTemplatesCompatibility.php b/plugins/woocommerce/src/Blocks/Templates/ArchiveProductTemplatesCompatibility.php index 74f36155582..6fba40e7149 100644 --- a/plugins/woocommerce/src/Blocks/Templates/ArchiveProductTemplatesCompatibility.php +++ b/plugins/woocommerce/src/Blocks/Templates/ArchiveProductTemplatesCompatibility.php @@ -320,7 +320,7 @@ class ArchiveProductTemplatesCompatibility extends AbstractTemplateCompatibility } /** - * Check if block is within the product-query namespace + * Check whether block is within the product-query namespace. * * @param array $block Parsed block data. */ @@ -331,7 +331,7 @@ class ArchiveProductTemplatesCompatibility extends AbstractTemplateCompatibility } /** - * Check if block has isInherited attribute asigned + * Check whether block has isInherited attribute assigned. * * @param array $block Parsed block data. */ @@ -357,7 +357,7 @@ class ArchiveProductTemplatesCompatibility extends AbstractTemplateCompatibility } /** - * Check if block is a Post template + * Check whether block is a Post template. * * @param string $block_name Block name. */ @@ -366,7 +366,7 @@ class ArchiveProductTemplatesCompatibility extends AbstractTemplateCompatibility } /** - * Check if block is a Product Template + * Check whether block is a Product Template. * * @param string $block_name Block name. */ @@ -375,7 +375,7 @@ class ArchiveProductTemplatesCompatibility extends AbstractTemplateCompatibility } /** - * Check if block is eaither a Post template or Product Template + * Check if block is either a Post template or a Product Template * * @param string $block_name Block name. */ diff --git a/plugins/woocommerce/src/Blocks/Templates/OrderConfirmationTemplate.php b/plugins/woocommerce/src/Blocks/Templates/OrderConfirmationTemplate.php index 29db821623e..d789aa538f2 100644 --- a/plugins/woocommerce/src/Blocks/Templates/OrderConfirmationTemplate.php +++ b/plugins/woocommerce/src/Blocks/Templates/OrderConfirmationTemplate.php @@ -20,7 +20,7 @@ class OrderConfirmationTemplate extends AbstractPageTemplate { */ public function init() { add_action( 'wp_before_admin_bar_render', array( $this, 'remove_edit_page_link' ) ); - + add_filter( 'pre_get_document_title', array( $this, 'page_template_title' ) ); parent::init(); } diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductAttributeTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductAttributeTemplate.php index 828133704a3..153eadb3c9f 100644 --- a/plugins/woocommerce/src/Blocks/Templates/ProductAttributeTemplate.php +++ b/plugins/woocommerce/src/Blocks/Templates/ProductAttributeTemplate.php @@ -2,6 +2,7 @@ namespace Automattic\WooCommerce\Blocks\Templates; +use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility; use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils; /** @@ -61,6 +62,9 @@ class ProductAttributeTemplate extends AbstractTemplate { } if ( isset( $queried_object->taxonomy ) && taxonomy_is_product_attribute( $queried_object->taxonomy ) ) { + $compatibility_layer = new ArchiveProductTemplatesCompatibility(); + $compatibility_layer->init(); + $templates = get_block_templates( array( 'slug__in' => array( self::SLUG ) ) ); if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) { diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductCatalogTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductCatalogTemplate.php index 2f3d3383ccb..7059ed379b9 100644 --- a/plugins/woocommerce/src/Blocks/Templates/ProductCatalogTemplate.php +++ b/plugins/woocommerce/src/Blocks/Templates/ProductCatalogTemplate.php @@ -2,6 +2,7 @@ namespace Automattic\WooCommerce\Blocks\Templates; +use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility; use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils; /** @@ -49,6 +50,9 @@ class ProductCatalogTemplate extends AbstractTemplate { */ public function render_block_template() { if ( ! is_embed() && ( is_post_type_archive( 'product' ) || is_page( wc_get_page_id( 'shop' ) ) ) ) { + $compatibility_layer = new ArchiveProductTemplatesCompatibility(); + $compatibility_layer->init(); + $templates = get_block_templates( array( 'slug__in' => array( self::SLUG ) ) ); if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) { diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductCategoryTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductCategoryTemplate.php index 604cbb51aa7..233d7c2a877 100644 --- a/plugins/woocommerce/src/Blocks/Templates/ProductCategoryTemplate.php +++ b/plugins/woocommerce/src/Blocks/Templates/ProductCategoryTemplate.php @@ -2,6 +2,7 @@ namespace Automattic\WooCommerce\Blocks\Templates; +use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility; use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils; /** @@ -55,6 +56,9 @@ class ProductCategoryTemplate extends AbstractTemplate { */ public function render_block_template() { if ( ! is_embed() && is_product_taxonomy() && is_tax( 'product_cat' ) ) { + $compatibility_layer = new ArchiveProductTemplatesCompatibility(); + $compatibility_layer->init(); + $templates = get_block_templates( array( 'slug__in' => array( self::SLUG ) ) ); if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) { diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductFiltersOverlayTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductFiltersOverlayTemplate.php index 6070ff238ef..38c19590275 100644 --- a/plugins/woocommerce/src/Blocks/Templates/ProductFiltersOverlayTemplate.php +++ b/plugins/woocommerce/src/Blocks/Templates/ProductFiltersOverlayTemplate.php @@ -20,14 +20,12 @@ class ProductFiltersOverlayTemplate extends AbstractTemplatePart { * * @var string */ - public $template_area = 'product-filters-overlay'; + public $template_area = 'uncategorized'; /** * Initialization method. */ - public function init() { - add_filter( 'default_wp_template_part_areas', array( $this, 'register_product_filters_overlay_template_part_area' ), 10, 1 ); - } + public function init() {} /** * Returns the title of the template. @@ -46,21 +44,4 @@ class ProductFiltersOverlayTemplate extends AbstractTemplatePart { public function get_template_description() { return __( 'Template used to display the Product Filters Overlay.', 'woocommerce' ); } - - /** - * Add Filters Overlay to the default template part areas. - * - * @param array $default_area_definitions An array of supported area objects. - * @return array The supported template part areas including the Filters Overlay one. - */ - public function register_product_filters_overlay_template_part_area( $default_area_definitions ) { - $product_filters_overlay_template_part_area = array( - 'area' => 'product-filters-overlay', - 'label' => $this->get_template_title(), - 'description' => $this->get_template_description(), - 'icon' => 'filter', - 'area_tag' => 'product-filters-overlay', - ); - return array_merge( $default_area_definitions, array( $product_filters_overlay_template_part_area ) ); - } } diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductFiltersTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductFiltersTemplate.php new file mode 100644 index 00000000000..ece799a312f --- /dev/null +++ b/plugins/woocommerce/src/Blocks/Templates/ProductFiltersTemplate.php @@ -0,0 +1,95 @@ +name ) { + return $variations; + } + + // If template part is modified, Core will pick it up and register a variation + // for it. Check if the variation already exists before adding it. + foreach ( $variations as $variation ) { + if ( ! empty( $variation['attributes']['slug'] ) && 'product-filters' === $variation['attributes']['slug'] ) { + return $variations; + } + } + + $theme = 'woocommerce/woocommerce'; + // Check if current theme overrides this template part. + if ( BlockTemplateUtils::theme_has_template_part( 'product-filters' ) ) { + $theme = wp_get_theme()->get( 'TextDomain' ); + } + + $variations[] = array( + 'name' => 'file_' . self::SLUG, + 'title' => $this->get_template_title(), + 'description' => true, + 'attributes' => array( + 'slug' => self::SLUG, + 'theme' => $theme, + 'area' => $this->template_area, + ), + 'scope' => array( 'inserter' ), + 'icon' => 'layout', + ); + return $variations; + } +} diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductSearchResultsTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductSearchResultsTemplate.php index ea8daab4768..c421fa7a0a0 100644 --- a/plugins/woocommerce/src/Blocks/Templates/ProductSearchResultsTemplate.php +++ b/plugins/woocommerce/src/Blocks/Templates/ProductSearchResultsTemplate.php @@ -1,6 +1,7 @@ init(); + $templates = get_block_templates( array( 'slug__in' => array( self::SLUG ) ) ); if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) { diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductTagTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductTagTemplate.php index 6049f429dcd..fef90fdc5ad 100644 --- a/plugins/woocommerce/src/Blocks/Templates/ProductTagTemplate.php +++ b/plugins/woocommerce/src/Blocks/Templates/ProductTagTemplate.php @@ -2,6 +2,7 @@ namespace Automattic\WooCommerce\Blocks\Templates; +use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility; use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils; /** @@ -55,6 +56,9 @@ class ProductTagTemplate extends AbstractTemplate { */ public function render_block_template() { if ( ! is_embed() && is_product_taxonomy() && is_tax( 'product_tag' ) ) { + $compatibility_layer = new ArchiveProductTemplatesCompatibility(); + $compatibility_layer->init(); + $templates = get_block_templates( array( 'slug__in' => array( self::SLUG ) ) ); if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) { diff --git a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php index 942f807422f..839ed177e09 100644 --- a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php +++ b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php @@ -23,7 +23,7 @@ class SingleProductTemplate extends AbstractTemplate { */ public function init() { add_action( 'template_redirect', array( $this, 'render_block_template' ) ); - add_filter( 'get_block_templates', array( $this, 'update_single_product_content' ), 11, 3 ); + add_filter( 'get_block_templates', array( $this, 'update_single_product_content' ), 11, 1 ); } /** @@ -51,13 +51,34 @@ class SingleProductTemplate extends AbstractTemplate { if ( ! is_embed() && is_singular( 'product' ) ) { global $post; - $valid_slugs = array( self::SLUG ); - if ( 'product' === $post->post_type && $post->post_name ) { + $compatibility_layer = new SingleProductTemplateCompatibility(); + $compatibility_layer->init(); + + $valid_slugs = array( self::SLUG ); + $single_product_slug = 'product' === $post->post_type && $post->post_name ? 'single-product-' . $post->post_name : ''; + if ( $single_product_slug ) { $valid_slugs[] = 'single-product-' . $post->post_name; } $templates = get_block_templates( array( 'slug__in' => $valid_slugs ) ); - if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) { + if ( count( $templates ) === 0 ) { + return; + } + + // Use the first template by default. + $template = $templates[0]; + + // Check if there is a template matching the slug `single-product-{post_name}`. + if ( count( $valid_slugs ) > 1 && count( $templates ) > 1 ) { + foreach ( $templates as $t ) { + if ( $single_product_slug === $t->slug ) { + $template = $t; + break; + } + } + } + + if ( isset( $template ) && BlockTemplateUtils::template_has_legacy_template_block( $template ) ) { add_filter( 'woocommerce_disable_compatibility_layer', '__return_true' ); } @@ -68,12 +89,10 @@ class SingleProductTemplate extends AbstractTemplate { /** * Add the block template objects to be used. * - * @param array $query_result Array of template objects. - * @param array $query Optional. Arguments to retrieve templates. - * @param string $template_type wp_template or wp_template_part. + * @param array $query_result Array of template objects. * @return array */ - public function update_single_product_content( $query_result, $query, $template_type ) { + public function update_single_product_content( $query_result ) { $query_result = array_map( function ( $template ) { if ( str_contains( $template->slug, self::SLUG ) ) { diff --git a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplateCompatibility.php b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplateCompatibility.php index a76f974d32b..caa7b691576 100644 --- a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplateCompatibility.php +++ b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplateCompatibility.php @@ -1,6 +1,8 @@ array( 'woocommerce_before_main_content' => $this->hook_data['woocommerce_before_main_content'], 'woocommerce_before_single_product' => $this->hook_data['woocommerce_before_single_product'], + 'woocommerce_before_single_product_summary' => $this->hook_data['woocommerce_before_single_product_summary'], ), 'after' => array(), ); @@ -195,7 +198,7 @@ class SingleProductTemplateCompatibility extends AbstractTemplateCompatibility { ), ), 'woocommerce_before_single_product_summary' => array( - 'block_names' => array( 'core/post-excerpt' ), + 'block_names' => array(), 'position' => 'before', 'hooked' => array( 'woocommerce_show_product_sale_flash' => 10, @@ -255,15 +258,11 @@ class SingleProductTemplateCompatibility extends AbstractTemplateCompatibility { * @return string */ public static function add_compatibility_layer( $template_content ) { - $parsed_blocks = parse_blocks( $template_content ); - - if ( ! self::has_single_product_template_blocks( $parsed_blocks ) ) { - $template = self::inject_custom_attributes_to_first_and_last_block_single_product_template( $parsed_blocks ); - return self::serialize_blocks( $template ); + $blocks = parse_blocks( $template_content ); + if ( self::has_single_product_template_blocks( $blocks ) ) { + $blocks = self::wrap_single_product_template( $template_content ); } - - $wrapped_blocks = self::wrap_single_product_template( $template_content ); - $template = self::inject_custom_attributes_to_first_and_last_block_single_product_template( $wrapped_blocks ); + $template = self::inject_custom_attributes_to_first_and_last_block_single_product_template( $blocks ); return self::serialize_blocks( $template ); } @@ -383,7 +382,7 @@ class SingleProductTemplateCompatibility extends AbstractTemplateCompatibility { /** * Check if the Single Product template has a single product template block: - * woocommerce/product-gallery-image, woocommerce/product-details, woocommerce/add-to-cart-form] + * woocommerce/product-gallery-image, woocommerce/product-details, woocommerce/add-to-cart-form, etc. * * @param array $parsed_blocks Array of parsed block objects. * @return bool True if the template has a single product template block, false otherwise. @@ -391,19 +390,7 @@ class SingleProductTemplateCompatibility extends AbstractTemplateCompatibility { private static function has_single_product_template_blocks( $parsed_blocks ) { $single_product_template_blocks = array( 'woocommerce/product-image-gallery', 'woocommerce/product-details', 'woocommerce/add-to-cart-form', 'woocommerce/product-meta', 'woocommerce/product-price', 'woocommerce/breadcrumbs' ); - $found = false; - - foreach ( $parsed_blocks as $block ) { - if ( isset( $block['blockName'] ) && in_array( $block['blockName'], $single_product_template_blocks, true ) ) { - $found = true; - break; - } - $found = self::has_single_product_template_blocks( $block['innerBlocks'], $single_product_template_blocks ); - if ( $found ) { - break; - } - } - return $found; + return BlockTemplateUtils::has_block_including_patterns( $single_product_template_blocks, $parsed_blocks ); } diff --git a/plugins/woocommerce/src/Blocks/Utils/BlockHooksTrait.php b/plugins/woocommerce/src/Blocks/Utils/BlockHooksTrait.php index 4ac4282378c..2bbc2a89637 100644 --- a/plugins/woocommerce/src/Blocks/Utils/BlockHooksTrait.php +++ b/plugins/woocommerce/src/Blocks/Utils/BlockHooksTrait.php @@ -18,31 +18,34 @@ trait BlockHooksTrait { * @return array An array of block slugs hooked into a given context. */ public function register_hooked_block( $hooked_blocks, $position, $anchor_block, $context ) { - - /** - * If the block has no hook placements, return early. - */ + // If the block has no hook placements, return early. if ( ! isset( $this->hooked_block_placements ) || empty( $this->hooked_block_placements ) ) { return $hooked_blocks; } - // Cache for active theme. - static $active_theme_name = null; - if ( is_null( $active_theme_name ) ) { - $active_theme_name = wp_get_theme()->get( 'Name' ); + // Cache the block hooks version. + static $block_hooks_version = null; + if ( defined( 'WP_RUN_CORE_TESTS' ) || is_null( $block_hooks_version ) ) { + $block_hooks_version = get_option( 'woocommerce_hooked_blocks_version' ); } - /** - * A list of theme slugs to execute this with. This is a temporary - * measure until improvements to the Block Hooks API allow for exposing - * to all block themes. - * - * @since 8.4.0 - */ - $theme_include_list = apply_filters( 'woocommerce_hooked_blocks_theme_include_list', array( 'Twenty Twenty-Four', 'Twenty Twenty-Three', 'Twenty Twenty-Two', 'Tsubaki', 'Zaino', 'Thriving Artist', 'Amulet', 'Tazza' ) ); + // If block hooks are disabled or the version is not set, return early. + if ( 'no' === $block_hooks_version || false === $block_hooks_version ) { + return $hooked_blocks; + } - if ( $context && in_array( $active_theme_name, $theme_include_list, true ) ) { - foreach ( $this->hooked_block_placements as $placement ) { + // Valid placements are those that have no version specified, + // or have a version that is less than or equal to version specified in the woocommerce_hooked_blocks_version option. + $valid_placements = array_filter( + $this->hooked_block_placements, + function ( $placement ) use ( $block_hooks_version ) { + $placement_version = isset( $placement['version'] ) ? $placement['version'] : null; + return is_null( $placement_version ) || ! is_null( $placement_version ) && version_compare( $block_hooks_version, $placement_version, '>=' ); + } + ); + + if ( $context && ! empty( $valid_placements ) ) { + foreach ( $valid_placements as $placement ) { if ( $placement['position'] === $position && $placement['anchor'] === $anchor_block ) { // If an area has been specified for this placement. diff --git a/plugins/woocommerce/src/Blocks/Utils/BlockTemplateUtils.php b/plugins/woocommerce/src/Blocks/Utils/BlockTemplateUtils.php index c47b10aa1c2..595ceca323e 100644 --- a/plugins/woocommerce/src/Blocks/Utils/BlockTemplateUtils.php +++ b/plugins/woocommerce/src/Blocks/Utils/BlockTemplateUtils.php @@ -1,6 +1,7 @@ fallback_template ) ) { - return ProductCatalogTemplate::SLUG === $registered_template->fallback_template; - } - return false; - } - /** * Checks if we can fall back to an `archive-product` template stored on the db for a given slug. * @@ -529,20 +517,21 @@ class BlockTemplateUtils { * @param array $db_templates Templates that have already been found on the db. * @return boolean */ - public static function template_is_eligible_for_product_archive_fallback_from_db( $template_slug, $db_templates ) { - $eligible_for_fallback = self::template_is_eligible_for_product_archive_fallback( $template_slug ); - if ( ! $eligible_for_fallback ) { - return false; + public static function template_is_eligible_for_fallback_from_db( $template_slug, $db_templates ) { + $registered_template = self::get_template( $template_slug ); + + if ( $registered_template && isset( $registered_template->fallback_template ) ) { + $array_filter = array_filter( + $db_templates, + function ( $template ) use ( $registered_template ) { + return isset( $registered_template->fallback_template ) && $registered_template->fallback_template === $template->slug; + } + ); + + return count( $array_filter ) > 0; } - $array_filter = array_filter( - $db_templates, - function ( $template ) use ( $template_slug ) { - return ProductCatalogTemplate::SLUG === $template->slug; - } - ); - - return count( $array_filter ) > 0; + return false; } /** @@ -553,14 +542,13 @@ class BlockTemplateUtils { * @return boolean|object */ public static function get_fallback_template_from_db( $template_slug, $db_templates ) { - $eligible_for_fallback = self::template_is_eligible_for_product_archive_fallback( $template_slug ); - if ( ! $eligible_for_fallback ) { - return false; - } + $registered_template = self::get_template( $template_slug ); - foreach ( $db_templates as $template ) { - if ( ProductCatalogTemplate::SLUG === $template->slug ) { - return $template; + if ( $registered_template && isset( $registered_template->fallback_template ) ) { + foreach ( $db_templates as $template ) { + if ( $registered_template->fallback_template === $template->slug ) { + return $template; + } } } @@ -576,10 +564,12 @@ class BlockTemplateUtils { * @param string $template_slug Slug to check for fallbacks. * @return boolean */ - public static function template_is_eligible_for_product_archive_fallback_from_theme( $template_slug ) { - return self::template_is_eligible_for_product_archive_fallback( $template_slug ) + public static function template_is_eligible_for_fallback_from_theme( $template_slug ) { + $registered_template = self::get_template( $template_slug ); + + return $registered_template && isset( $registered_template->fallback_template ) && ! self::theme_has_template( $template_slug ) - && self::theme_has_template( ProductCatalogTemplate::SLUG ); + && self::theme_has_template( $registered_template->fallback_template ); } /** @@ -605,7 +595,7 @@ class BlockTemplateUtils { $query_result_template->slug === $template->slug && $query_result_template->theme === $template->theme ) { - if ( self::template_is_eligible_for_product_archive_fallback_from_theme( $template->slug ) ) { + if ( self::template_is_eligible_for_fallback_from_theme( $template->slug ) ) { $query_result_template->has_theme_file = true; } @@ -707,6 +697,38 @@ class BlockTemplateUtils { return wc_string_to_bool( $use_blockified_templates ); } + /** + * Determines whether the provided $blocks contains any of the $block_names, + * or if they contain a pattern that contains any of the $block_names. + * + * @param string[] $block_names Full block types to look for. + * @param WP_Block[] $blocks Array of block objects. + * @return bool Whether the content contains the specified block. + */ + public static function has_block_including_patterns( $block_names, $blocks ) { + $flattened_blocks = self::flatten_blocks( $blocks ); + + foreach ( $flattened_blocks as &$block ) { + if ( isset( $block['blockName'] ) && in_array( $block['blockName'], $block_names, true ) ) { + return true; + } + if ( + 'core/pattern' === $block['blockName'] && + isset( $block['attrs']['slug'] ) + ) { + $registry = WP_Block_Patterns_Registry::get_instance(); + $pattern = $registry->get_registered( $block['attrs']['slug'] ); + $pattern_blocks = parse_blocks( $pattern['content'] ); + + if ( self::has_block_including_patterns( $block_names, $pattern_blocks ) ) { + return true; + } + } + } + + return false; + } + /** * Returns whether the passed `$template` has the legacy template block. * @@ -714,7 +736,13 @@ class BlockTemplateUtils { * @return boolean */ public static function template_has_legacy_template_block( $template ) { - return has_block( 'woocommerce/legacy-template', $template->content ); + if ( has_block( 'woocommerce/legacy-template', $template->content ) ) { + return true; + } + + $blocks = parse_blocks( $template->content ); + + return self::has_block_including_patterns( array( 'woocommerce/legacy-template' ), $blocks ); } /** diff --git a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php index 4492c7cb08f..78c6cefe9e0 100644 --- a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php +++ b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php @@ -59,7 +59,8 @@ class ProductGalleryUtils { wp_json_encode( array( 'imageId' => $product_gallery_image_id, - ) + ), + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ); diff --git a/plugins/woocommerce/src/Caching/ObjectCache.php b/plugins/woocommerce/src/Caching/ObjectCache.php index 925509d17b4..646308a3c32 100644 --- a/plugins/woocommerce/src/Caching/ObjectCache.php +++ b/plugins/woocommerce/src/Caching/ObjectCache.php @@ -166,7 +166,7 @@ abstract class ObjectCache { * @param object|array $object The new object that will replace the already cached one. * @param int|string|null $id Id of the object to be cached, if null, get_object_id will be used to get it. * @param int $expiration Expiration of the cached data in seconds from the current time, or DEFAULT_EXPIRATION to use the default value. - * @return bool True on success, false on error or if no object wiith the supplied id was cached. + * @return bool True on success, false on error or if no object with the supplied id was cached. * @throws CacheException Invalid parameter, or null id was passed and get_object_id returns null too. */ public function update_if_cached( $object, $id = null, int $expiration = self::DEFAULT_EXPIRATION ): bool { diff --git a/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php b/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php index f7d2894ba22..10b1a60a933 100644 --- a/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php +++ b/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php @@ -88,7 +88,7 @@ final class ReserveStock { try { $items = array_filter( $order->get_items(), - function( $item ) { + function ( $item ) { return $item->is_type( 'line_item' ) && $item->get_product() instanceof \WC_Product && $item->get_quantity() > 0; } ); diff --git a/plugins/woocommerce/src/Container.php b/plugins/woocommerce/src/Container.php index 8a1d5d9fbf3..56d3b5b1e98 100644 --- a/plugins/woocommerce/src/Container.php +++ b/plugins/woocommerce/src/Container.php @@ -31,6 +31,7 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\UtilsC use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\BatchProcessingServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\LayoutTemplatesServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ComingSoonServiceProvider; +use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\StatsServiceProvider; /** * PSR11 compliant dependency injection container for WooCommerce. @@ -81,6 +82,7 @@ final class Container { LoggingServiceProvider::class, EnginesServiceProvider::class, ComingSoonServiceProvider::class, + StatsServiceProvider::class, ); /** diff --git a/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php b/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php index aa804aef3a8..ab60693811a 100644 --- a/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php +++ b/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php @@ -330,7 +330,7 @@ class CLIRunner { * --- * * [--order-types] - * : Comma seperated list of order types that needs to be verified. For example, --order-types=shop_order,shop_order_refund + * : Comma-separated list of order types that needs to be verified. For example, --order-types=shop_order,shop_order_refund * --- * default: Output of function `wc_get_order_types( 'cot-migration' )` * @@ -385,7 +385,7 @@ class CLIRunner { if ( 0 === count( $order_types ) ) { return WP_CLI::error( sprintf( - /* Translators: %s is the comma seperated list of order types. */ + /* Translators: %s is the comma-separated list of order types. */ __( 'Passed order type does not match any registered order types. Following order types are registered: %s', 'woocommerce' ), implode( ',', wc_get_order_types( 'cot-migration' ) ) ) @@ -927,7 +927,7 @@ ORDER BY $meta_table.order_id ASC, $meta_table.meta_key ASC; * # Cleanup post data for order 314. * $ wp wc hpos cleanup 314 * - * # Cleanup postmeta for orders with IDs betweeen 10 and 100 and order 314. + * # Cleanup postmeta for orders with IDs between 10 and 100 and order 314. * $ wp wc hpos cleanup 10-100 314 * * # Cleanup postmeta for all orders. @@ -1161,10 +1161,10 @@ ORDER BY $meta_table.order_id ASC, $meta_table.meta_key ASC; * --- * * [--meta_keys=] - * : Comma separated list of meta keys to backfill. + * : Comma-separated list of meta keys to backfill. * * [--props=] - * : Comma separated list of order properties to backfill. + * : Comma-separated list of order properties to backfill. * * @since 8.6.0 * diff --git a/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php b/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php index 1aa9740e146..573fdedace3 100644 --- a/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php +++ b/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php @@ -134,7 +134,7 @@ abstract class MetaToCustomTableMigrator extends TableMigrator { * @return string Generated queries for batch update. Would be of the form: * INSERT INTO $table ( $columns ) VALUES * ($value for row 1) - * ($valye for row 2) + * ($value for row 2) * ... * ON DUPLICATE KEY UPDATE * $column1 = VALUES($column1) diff --git a/plugins/woocommerce/src/Database/Migrations/TableMigrator.php b/plugins/woocommerce/src/Database/Migrations/TableMigrator.php index 4d6665c08f8..e9b2f0a283b 100644 --- a/plugins/woocommerce/src/Database/Migrations/TableMigrator.php +++ b/plugins/woocommerce/src/Database/Migrations/TableMigrator.php @@ -14,7 +14,7 @@ namespace Automattic\WooCommerce\Database\Migrations; abstract class TableMigrator { /** - * An array of cummulated error messages. + * An array of cumulated error messages. * * @var array */ diff --git a/plugins/woocommerce/src/Internal/Admin/Homescreen.php b/plugins/woocommerce/src/Internal/Admin/Homescreen.php index 3356ed5c67f..1a9cd90f776 100644 --- a/plugins/woocommerce/src/Internal/Admin/Homescreen.php +++ b/plugins/woocommerce/src/Internal/Admin/Homescreen.php @@ -63,7 +63,7 @@ class Homescreen { /** * Set free shipping in the same country as the store default - * Flag rate in all other countries when any of the following conditions are ture + * Flag rate in all other countries when any of the following conditions are true * * - The store sells physical products, has JP and WCS installed and connected, and is located in the US. * - The store sells physical products, and is not located in US/Canada/Australia/UK (irrelevant if JP is installed or not). @@ -242,7 +242,7 @@ class Homescreen { */ public function update_link_structure() { global $submenu; - // User does not have capabilites to see the submenu. + // User does not have capabilities to see the submenu. if ( ! current_user_can( 'manage_woocommerce' ) || empty( $submenu['woocommerce'] ) ) { return; } diff --git a/plugins/woocommerce/src/Internal/Admin/Loader.php b/plugins/woocommerce/src/Internal/Admin/Loader.php index 41e11ae613d..21caf32b9ba 100644 --- a/plugins/woocommerce/src/Internal/Admin/Loader.php +++ b/plugins/woocommerce/src/Internal/Admin/Loader.php @@ -111,7 +111,7 @@ class Loader { /** * Set up a div for the header embed to render into. - * The initial contents here are meant as a place loader for when the PHP page initialy loads. + * The initial contents here are meant as a place loader for when the PHP page initially loads. */ public static function embed_page_header() { if ( ! PageController::is_admin_page() && ! PageController::is_embed_page() ) { @@ -541,7 +541,7 @@ class Loader { } /** - * Return an object defining the currecy options for the site's current currency + * Return an object defining the currency options for the site's current currency * * @return array Settings for the current currency { * Array of settings. diff --git a/plugins/woocommerce/src/Internal/Admin/Logging/FileV2/FileController.php b/plugins/woocommerce/src/Internal/Admin/Logging/FileV2/FileController.php index aec4907eabb..68268a80c5b 100644 --- a/plugins/woocommerce/src/Internal/Admin/Logging/FileV2/FileController.php +++ b/plugins/woocommerce/src/Internal/Admin/Logging/FileV2/FileController.php @@ -656,7 +656,7 @@ class FileController { */ public function get_log_directory_size(): int { $bytes = 0; - $path = realpath( Settings::get_log_directory() ); + $path = realpath( Settings::get_log_directory( false ) ); if ( wp_is_writable( $path ) ) { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $path, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CATCH_GET_CHILD ); diff --git a/plugins/woocommerce/src/Internal/Admin/Logging/Settings.php b/plugins/woocommerce/src/Internal/Admin/Logging/Settings.php index 61adc0087b0..2863c19c68c 100644 --- a/plugins/woocommerce/src/Internal/Admin/Logging/Settings.php +++ b/plugins/woocommerce/src/Internal/Admin/Logging/Settings.php @@ -54,13 +54,15 @@ class Settings { * The `wp_upload_dir` function takes into account the possibility of multisite, and handles changing * the directory if the context is switched to a different site in the network mid-request. * + * @param bool $create_dir Optional. True to attempt to create the log directory if it doesn't exist. Default true. + * * @return string The full directory path, with trailing slash. */ - public static function get_log_directory(): string { + public static function get_log_directory( bool $create_dir = true ): string { if ( true === Constants::get_constant( 'WC_LOG_DIR_CUSTOM' ) ) { $dir = Constants::get_constant( 'WC_LOG_DIR' ); } else { - $upload_dir = wc_get_container()->get( LegacyProxy::class )->call_function( 'wp_upload_dir' ); + $upload_dir = wc_get_container()->get( LegacyProxy::class )->call_function( 'wp_upload_dir', null, $create_dir ); /** * Filter to change the directory for storing WooCommerce's log files. @@ -74,18 +76,20 @@ class Settings { $dir = trailingslashit( $dir ); - $realpath = realpath( $dir ); - if ( false === $realpath ) { - $result = wp_mkdir_p( $dir ); + if ( true === $create_dir ) { + $realpath = realpath( $dir ); + if ( false === $realpath ) { + $result = wp_mkdir_p( $dir ); - if ( true === $result ) { - // Create infrastructure to prevent listing contents of the logs directory. - try { - $filesystem = FilesystemUtil::get_wp_filesystem(); - $filesystem->put_contents( $dir . '.htaccess', 'deny from all' ); - $filesystem->put_contents( $dir . 'index.html', '' ); - } catch ( Exception $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch - // Creation failed. + if ( true === $result ) { + // Create infrastructure to prevent listing contents of the logs directory. + try { + $filesystem = FilesystemUtil::get_wp_filesystem(); + $filesystem->put_contents( $dir . '.htaccess', 'deny from all' ); + $filesystem->put_contents( $dir . 'index.html', '' ); + } catch ( Exception $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + // Creation failed. + } } } } diff --git a/plugins/woocommerce/src/Internal/Admin/Marketing.php b/plugins/woocommerce/src/Internal/Admin/Marketing.php index b9efa6461f6..2c0d4348bd8 100644 --- a/plugins/woocommerce/src/Internal/Admin/Marketing.php +++ b/plugins/woocommerce/src/Internal/Admin/Marketing.php @@ -158,7 +158,7 @@ class Marketing { } /** - * Order marketing menu items alphabeticaly. + * Order marketing menu items alphabetically. * Overview should be first, and Coupons should be second, followed by other marketing menu items. * * @return void @@ -178,7 +178,7 @@ class Marketing { if ( false === $overview_key ) { /* - * If Overview is not found we may be on a site witha different language. + * If Overview is not found, we may be on a site with a different language. * We can use a fallback and try to find the overview page by its path. */ $overview_key = array_search( 'admin.php?page=wc-admin&path=/marketing', array_column( $marketing_submenu, self::SUBMENU_LOCATION_KEY ), true ); @@ -194,7 +194,7 @@ class Marketing { if ( false === $coupons_key ) { /* - * If Coupons is not found we may be on a site witha different language. + * If Coupons is not found, we may be on a site with a different language. * We can use a fallback and try to find the coupons page by its path. */ $coupons_key = array_search( 'edit.php?post_type=shop_coupon', array_column( $marketing_submenu, self::SUBMENU_LOCATION_KEY ), true ); diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php b/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php index fdf819dda31..fad1838532a 100644 --- a/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php +++ b/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php @@ -170,7 +170,7 @@ class Edit { * * @since 7.4.0 * - * @oaram WC_Order $order The order being edited. + * @param WC_Order $order The order being edited. */ do_action( 'add_meta_boxes_' . $this->screen_id, $this->order ); diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php b/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php index b568f1effa6..d507b53d590 100644 --- a/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php +++ b/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php @@ -216,7 +216,7 @@ class ListTable extends WP_List_Table { * * @return mixed */ - public function set_items_per_page( $default, string $option, int $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; } @@ -754,6 +754,8 @@ class ListTable extends WP_List_Table { * @return void */ private function months_filter() { + global $wp_locale; + // XXX: [review] we may prefer to move this logic outside of the ListTable class. /** @@ -767,29 +769,12 @@ class ListTable extends WP_List_Table { return; } - global $wp_locale; - global $wpdb; - - $orders_table = esc_sql( OrdersTableDataStore::get_orders_table_name() ); - $utc_offset = wc_timezone_offset(); - - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $order_dates = $wpdb->get_results( - $wpdb->prepare( - " - SELECT DISTINCT YEAR( t.date_created_local ) AS year, - MONTH( t.date_created_local ) AS month - FROM ( SELECT DATE_ADD( date_created_gmt, INTERVAL $utc_offset SECOND ) AS date_created_local FROM $orders_table WHERE type = %s AND status != 'trash' ) t - ORDER BY year DESC, month DESC - ", - $this->order_type - ) - ); - $m = isset( $_GET['m'] ) ? (int) $_GET['m'] : 0; echo ''; } + /** + * Get order year-months cache. We cache the results in the options table, since these results will change very infrequently. + * We use the heuristic to always return current year-month when getting from cache to prevent an additional query. + * + * @return array List of year-months. + */ + protected function get_and_maybe_update_months_filter_cache(): array { + global $wpdb; + + // We cache in the options table since it's won't be invalidated soon. + $cache_option_value_name = 'wc_' . $this->order_type . '_list_table_months_filter_cache_value'; + $cache_option_date_name = 'wc_' . $this->order_type . '_list_table_months_filter_cache_date'; + + $cached_timestamp = get_option( $cache_option_date_name, 0 ); + + // new day, new cache. + if ( 0 === $cached_timestamp || gmdate( 'j', time() ) !== gmdate( 'j', $cached_timestamp ) || ( time() - $cached_timestamp ) > 60 * 60 * 24 ) { + $cached_value = false; + } else { + $cached_value = get_option( $cache_option_value_name ); + } + + if ( false !== $cached_value ) { + // Always add current year month for cache stability. This allows us to not hydrate the cache on every order update. + $current_year_month = new \stdClass(); + $current_year_month->year = gmdate( 'Y', time() ); + $current_year_month->month = gmdate( 'n', time() ); + if ( count( $cached_value ) === 0 || ( + $cached_value[0]->year !== $current_year_month->year || + $cached_value[0]->month !== $current_year_month->month ) + ) { + array_unshift( $cached_value, $current_year_month ); + } + return $cached_value; + } + + $orders_table = esc_sql( OrdersTableDataStore::get_orders_table_name() ); + $utc_offset = wc_timezone_offset(); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $order_dates = $wpdb->get_results( + $wpdb->prepare( + " + SELECT DISTINCT YEAR( t.date_created_local ) AS year, + MONTH( t.date_created_local ) AS month + FROM ( SELECT DATE_ADD( date_created_gmt, INTERVAL $utc_offset SECOND ) AS date_created_local FROM $orders_table WHERE type = %s AND status != 'trash' ) t + ORDER BY year DESC, month DESC + ", + $this->order_type + ) + ); + + update_option( $cache_option_date_name, time() ); + update_option( $cache_option_value_name, $order_dates ); + + return $order_dates; + } + /** * Render the customer filter dropdown. * @@ -976,6 +1019,14 @@ class ListTable extends WP_List_Table { echo '' . esc_html( __( 'Preview', 'woocommerce' ) ) . ''; echo '#' . esc_attr( $order->get_order_number() ) . ' ' . esc_html( $buyer ) . ''; } + + // Used for showing date & status next to order number/buyer name on small screens. + echo '
'; + $this->render_order_date_column( $order ); + echo '
'; + echo '
'; + $this->render_order_status_column( $order ); + echo '
'; } /** @@ -1056,14 +1107,14 @@ class ListTable extends WP_List_Table { */ private function get_order_status_label( WC_Order $order ): string { $status_names = array( - 'Pending payment' => __( 'The order has been received, but no payment has been made. Pending payment orders are generally awaiting customer action.', 'woocommerce' ), - 'On hold' => __( 'The order is awaiting payment confirmation. Stock is reduced, but you need to confirm payment.', 'woocommerce' ), - 'Processing' => __( 'Payment has been received (paid), and the stock has been reduced. The order is awaiting fulfillment.', 'woocommerce' ), - 'Completed' => __( 'Order fulfilled and complete.', 'woocommerce' ), - 'Failed' => __( 'The customer’s payment failed or was declined, and no payment has been successfully made.', 'woocommerce' ), - 'Draft' => __( 'Draft orders are created when customers start the checkout process while the block version of the checkout is in place.', 'woocommerce' ), - 'Canceled' => __( 'The order was canceled by an admin or the customer.', 'woocommerce' ), - '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' ), + 'pending' => __( 'The order has been received, but no payment has been made. Pending payment orders are generally awaiting customer action.', 'woocommerce' ), + 'on-hold' => __( 'The order is awaiting payment confirmation. Stock is reduced, but you need to confirm payment.', 'woocommerce' ), + 'processing' => __( 'Payment has been received (paid), and the stock has been reduced. The order is awaiting fulfillment.', 'woocommerce' ), + 'completed' => __( 'Order fulfilled and complete.', 'woocommerce' ), + 'failed' => __( 'The customer’s payment failed or was declined, and no payment has been successfully made.', 'woocommerce' ), + 'checkout-draft' => __( 'Draft orders are created when customers start the checkout process while the block version of the checkout is in place.', 'woocommerce' ), + 'cancelled' => __( 'The order was canceled by an admin or the customer.', 'woocommerce' ), + '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' ), ); /** @@ -1073,9 +1124,9 @@ class ListTable extends WP_List_Table { * @param WC_Order $order Current order object. * @since 9.1.0 */ - $status_names = apply_filters( 'woocommerce_get_order_status_labels', $status_names ); + $status_names = apply_filters( 'woocommerce_get_order_status_labels', $status_names, $order ); - $status_name = wc_get_order_status_name( $order->get_status() ); + $status_name = $order->get_status(); return isset( $status_names[ $status_name ] ) ? $status_names[ $status_name ] : ''; } diff --git a/plugins/woocommerce/src/Internal/Admin/ProductReviews/ReviewsListTable.php b/plugins/woocommerce/src/Internal/Admin/ProductReviews/ReviewsListTable.php index 131af7c87b4..fd6683672ec 100644 --- a/plugins/woocommerce/src/Internal/Admin/ProductReviews/ReviewsListTable.php +++ b/plugins/woocommerce/src/Internal/Admin/ProductReviews/ReviewsListTable.php @@ -632,7 +632,7 @@ class ReviewsListTable extends WP_List_Table { /** * Gets the name of the default primary column. * - * @return string Name of the primary colum. + * @return string Name of the primary column. */ protected function get_primary_column_name() : string { return 'comment'; diff --git a/plugins/woocommerce/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensions.php b/plugins/woocommerce/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensions.php index f81aa2c69ca..5e1faf15127 100644 --- a/plugins/woocommerce/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensions.php +++ b/plugins/woocommerce/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensions.php @@ -93,10 +93,10 @@ class DefaultFreeExtensions { $plugins = array( 'google-listings-and-ads' => array( 'min_php_version' => '7.4', - 'name' => __( 'Google Listings & Ads', 'woocommerce' ), + 'name' => __( 'Google for WooCommerce', 'woocommerce' ), 'description' => sprintf( /* translators: 1: opening product link tag. 2: closing link tag */ - __( 'Drive sales with %1$sGoogle Listings and Ads%2$s', 'woocommerce' ), + __( 'Drive sales with %1$sGoogle for WooCommerce%2$s', 'woocommerce' ), '', '' ), @@ -116,7 +116,7 @@ class DefaultFreeExtensions { ), ), 'google-listings-and-ads:alt' => array( - 'name' => __( 'Google Listings & Ads', 'woocommerce' ), + 'name' => __( 'Google for WooCommerce', 'woocommerce' ), 'description' => __( 'Reach more shoppers and drive sales for your store. Integrate with Google to list your products for free and launch paid ad campaigns.', 'woocommerce' ), 'image_url' => plugins_url( '/assets/images/onboarding/google.svg', WC_PLUGIN_FILE ), 'manage_url' => 'admin.php?page=wc-admin&path=%2Fgoogle%2Fstart', @@ -856,13 +856,13 @@ class DefaultFreeExtensions { ), 'tiktok-for-business' => array( 'label' => __( 'Create ad campaigns with TikTok', 'woocommerce' ), - 'image_url' => plugins_url( '/assets/images/core-profiler/logo-tiktok.svg', WC_PLUGIN_FILE ), + 'image_url' => plugins_url( '/assets/images/core-profiler/logo-tiktok.png', WC_PLUGIN_FILE ), 'description' => __( 'Create advertising campaigns and reach one billion global users.', 'woocommerce' ), 'learn_more_link' => 'https://woocommerce.com/products/tiktok-for-woocommerce?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures', 'install_priority' => 1, ), 'google-listings-and-ads' => array( - 'label' => __( 'Drive sales with Google Listings & Ads', 'woocommerce' ), + 'label' => __( 'Drive sales with Google for WooCommerce', 'woocommerce' ), 'image_url' => plugins_url( '/assets/images/core-profiler/logo-google.svg', WC_PLUGIN_FILE ), 'description' => __( 'Reach millions of active shoppers across Google with free product listings and ads.', 'woocommerce' ), 'learn_more_link' => 'https://woocommerce.com/products/google-listings-and-ads?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures', diff --git a/plugins/woocommerce/src/Internal/Admin/Schedulers/ImportScheduler.php b/plugins/woocommerce/src/Internal/Admin/Schedulers/ImportScheduler.php index 2f90a8d5d80..9922d19b5ad 100644 --- a/plugins/woocommerce/src/Internal/Admin/Schedulers/ImportScheduler.php +++ b/plugins/woocommerce/src/Internal/Admin/Schedulers/ImportScheduler.php @@ -60,7 +60,7 @@ abstract class ImportScheduler implements ImportInterface { * Get batch sizes. * * @internal - * @retun array + * @return array */ public static function get_batch_sizes() { return array_merge( @@ -96,7 +96,7 @@ abstract class ImportScheduler implements ImportInterface { * * @internal * @param integer|boolean $days Number of days to import. - * @param boolean $skip_existing Skip exisiting records. + * @param boolean $skip_existing Skip existing records. */ public static function import_batch_init( $days, $skip_existing ) { $batch_size = static::get_batch_size( 'import' ); @@ -117,7 +117,7 @@ abstract class ImportScheduler implements ImportInterface { * @internal * @param int $batch_number Batch number to import (essentially a query page number). * @param int|bool $days Number of days to import. - * @param bool $skip_existing Skip exisiting records. + * @param bool $skip_existing Skip existing records. * @return void */ public static function import_batch( $batch_number, $days, $skip_existing ) { diff --git a/plugins/woocommerce/src/Internal/Admin/Settings.php b/plugins/woocommerce/src/Internal/Admin/Settings.php index 2d47e46e0c3..a72ae43f098 100644 --- a/plugins/woocommerce/src/Internal/Admin/Settings.php +++ b/plugins/woocommerce/src/Internal/Admin/Settings.php @@ -78,7 +78,7 @@ class Settings { } /** - * Return an object defining the currecy options for the site's current currency + * Return an object defining the currency options for the site's current currency * * @return array Settings for the current currency { * Array of settings. @@ -258,7 +258,7 @@ class Settings { } /** - * Removes non necesary feature properties for the client side. + * Removes non-necessary feature properties for the client side. * * @return array */ diff --git a/plugins/woocommerce/src/Internal/Admin/WCAdminAssets.php b/plugins/woocommerce/src/Internal/Admin/WCAdminAssets.php index 11ac3e2893b..131c4f6c1e9 100644 --- a/plugins/woocommerce/src/Internal/Admin/WCAdminAssets.php +++ b/plugins/woocommerce/src/Internal/Admin/WCAdminAssets.php @@ -279,6 +279,7 @@ class WCAdminAssets { 'wc-navigation', 'wc-block-templates', 'wc-product-editor', + 'wc-remote-logging', ); $scripts_map = array( diff --git a/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/DefaultPromotions.php b/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/DefaultPromotions.php new file mode 100644 index 00000000000..16f9fd74b2e --- /dev/null +++ b/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/DefaultPromotions.php @@ -0,0 +1,86 @@ + 'woocommerce_payments:woopay', + 'title' => __( 'WooPayments', 'woocommerce' ), + 'content' => __( 'Payments made simple — including WooPay, a new express checkout feature.', 'woocommerce' ), + 'image' => plugins_url( 'assets/images/onboarding/wcpay.svg', WC_PLUGIN_FILE ), + 'plugins' => array( 'woocommerce-payments' ), + 'is_visible' => array( + DefaultPaymentGateways::get_rules_for_cbd( false ), + DefaultPaymentGateways::get_rules_for_countries( self::get_woopay_available_countries() ), + ), + 'sub_title' => self::get_wcpay_payment_icons(), + ), + array( + 'id' => 'woocommerce_payments', + 'title' => __( 'WooPayments', 'woocommerce' ), + 'content' => __( 'Payments made simple, with no monthly fees – designed exclusively for WooCommerce stores. Accept credit cards, debit cards, and other popular payment methods.', 'woocommerce' ), + 'image' => plugins_url( 'assets/images/onboarding/wcpay.svg', WC_PLUGIN_FILE ), + 'plugins' => array( 'woocommerce-payments' ), + 'is_visible' => array( + DefaultPaymentGateways::get_rules_for_cbd( false ), + DefaultPaymentGateways::get_rules_for_countries( DefaultPaymentGateways::get_wcpay_countries() ), + ), + 'sub_title' => self::get_wcpay_payment_icons(), + ), + ); + } + + /** + * Get the list of WooPay available countries. + * + * @return array The list of WooPay available countries. + */ + private static function get_woopay_available_countries(): array { + return array( 'US' ); + } + + /** + * Get the list of payment icons as HTML img tags. + * + * @return string Payment icons as HTML img tags. + */ + private static function get_wcpay_payment_icons(): string { + $icons = array( + 'visa', + 'mastercard', + 'amex', + 'googlepay', + 'applepay', + ); + $convert_to_img_tag = function ( $icon ) { + return sprintf( + '%s', + $icon, + plugins_url( "assets/images/payment-methods/$icon.svg", WC_PLUGIN_FILE ), + ucfirst( $icon ) + ); + }; + + return implode( '', array_map( $convert_to_img_tag, $icons ) ); + } +} diff --git a/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/Init.php b/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/Init.php index 7d4f5c21819..5fbf035600a 100644 --- a/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/Init.php +++ b/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/Init.php @@ -1,6 +1,6 @@ 'wc-wcpay-promotions' ) ); + $specs_to_return = $results['suggestions']; + $specs_to_save = null; + + if ( empty( $specs_to_return ) ) { + // When specs are empty, replace it with defaults and save for 3 hours. + $specs_to_save = DefaultPromotions::get_all(); + $specs_to_return = EvaluateSuggestion::evaluate_specs( $specs_to_save )['suggestions']; + } elseif ( count( $results['errors'] ) > 0 ) { + // When specs are not empty but have errors, save for 3 hours. + $specs_to_save = $specs; + } if ( count( $results['errors'] ) > 0 ) { - // Unlike payment gateway suggestions, we don't have a non-empty default set of promotions to fall back to. - // So just set the specs transient with expired time to 3 hours. - WCPayPromotionDataSourcePoller::get_instance()->set_specs_transient( array( $locale => $specs ), 3 * HOUR_IN_SECONDS ); self::log_errors( $results['errors'] ); } - return $results['suggestions']; + if ( $specs_to_save ) { + WCPayPromotionDataSourcePoller::get_instance()->set_specs_transient( array( $locale => $specs_to_save ), 3 * HOUR_IN_SECONDS ); + } + + return $specs_to_return; } /** * Get merchant WooPay eligibility. + * + * @return boolean If merchant is eligible for WooPay. */ public static function is_woopay_eligible() { $wcpay_promotion = self::get_wc_pay_promotion_spec(); @@ -144,12 +156,21 @@ class Init extends RemoteSpecsEngine { /** * Get specs or fetch remotely if they don't exist. + * + * @return array List of specs. */ public static function get_specs() { if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) === 'no' ) { - return array(); + return DefaultPromotions::get_all(); } - return WCPayPromotionDataSourcePoller::get_instance()->get_specs_from_data_sources(); + + $specs = WCPayPromotionDataSourcePoller::get_instance()->get_specs_from_data_sources(); + // On empty remote specs, fallback to default ones. + if ( ! is_array( $specs ) || 0 === count( $specs ) ) { + $specs = DefaultPromotions::get_all(); + } + + return $specs; } /** diff --git a/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/WCPayPromotionDataSourcePoller.php b/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/WCPayPromotionDataSourcePoller.php index 978c631f60a..515b7dbf8c8 100644 --- a/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/WCPayPromotionDataSourcePoller.php +++ b/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/WCPayPromotionDataSourcePoller.php @@ -2,10 +2,10 @@ namespace Automattic\WooCommerce\Internal\Admin\WCPayPromotion; -use Automattic\WooCommerce\Admin\DataSourcePoller; +use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller; /** - * Specs data source poller class for WooCommerce Payment Promotion. + * Specs data source poller class for WooPayments Promotion. */ class WCPayPromotionDataSourcePoller extends DataSourcePoller { @@ -30,7 +30,20 @@ class WCPayPromotionDataSourcePoller extends DataSourcePoller { */ public static function get_instance() { if ( ! self::$instance ) { - self::$instance = new self( self::ID, self::DATA_SOURCES ); + // Add country query param to data sources. + $base_location = wc_get_base_location(); + $data_sources = array_map( + function ( $url ) use ( $base_location ) { + return add_query_arg( + 'country', + $base_location['country'] ?? '', + $url + ); + }, + self::DATA_SOURCES + ); + + self::$instance = new self( self::ID, $data_sources ); } return self::$instance; } diff --git a/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/WCPaymentGatewayPreInstallWCPayPromotion.php b/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/WCPaymentGatewayPreInstallWCPayPromotion.php index ba4946043a6..bc163b8f3e5 100644 --- a/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/WCPaymentGatewayPreInstallWCPayPromotion.php +++ b/plugins/woocommerce/src/Internal/Admin/WCPayPromotion/WCPaymentGatewayPreInstallWCPayPromotion.php @@ -12,9 +12,9 @@ if ( ! defined( 'ABSPATH' ) ) { } /** - * A Psuedo WCPay gateway class. + * A pseudo WCPay gateway class. * - * @extends WC_Payment_Gateway + * @extends \WC_Payment_Gateway */ class WCPaymentGatewayPreInstallWCPayPromotion extends \WC_Payment_Gateway { @@ -87,7 +87,7 @@ class WCPaymentGatewayPreInstallWCPayPromotion extends \WC_Payment_Gateway { } /** - * Check if the promotional gateaway has been dismissed. + * Check if the promotional gateway has been dismissed. * * @return bool */ diff --git a/plugins/woocommerce/src/Internal/AssignDefaultCategory.php b/plugins/woocommerce/src/Internal/AssignDefaultCategory.php index 34968e974ee..ec4f47bdc4b 100644 --- a/plugins/woocommerce/src/Internal/AssignDefaultCategory.php +++ b/plugins/woocommerce/src/Internal/AssignDefaultCategory.php @@ -49,7 +49,7 @@ class AssignDefaultCategory { $default_category = get_option( 'default_product_cat', 0 ); if ( $default_category ) { - $wpdb->query( + $affected_rows = $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->term_relationships} (object_id, term_taxonomy_id) SELECT DISTINCT posts.ID, %s FROM {$wpdb->posts} posts @@ -65,9 +65,11 @@ class AssignDefaultCategory { $default_category ) ); - wp_cache_flush(); - delete_transient( 'wc_term_counts' ); - wp_update_term_count_now( array( $default_category ), 'product_cat' ); + if ( $affected_rows > 0 ) { + wp_cache_flush(); + delete_transient( 'wc_term_counts' ); + wp_update_term_count_now( array( $default_category ), 'product_cat' ); + } } } } diff --git a/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php b/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php index bffe26bc7aa..d0a502d4844 100644 --- a/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php +++ b/plugins/woocommerce/src/Internal/BatchProcessing/BatchProcessingController.php @@ -220,17 +220,19 @@ class BatchProcessingController { * @return array Current state for the processor, or a "blank" state if none exists yet. */ private function get_process_details( BatchProcessorInterface $batch_processor ): array { - return get_option( - $this->get_processor_state_option_name( $batch_processor ), - array( - 'total_time_spent' => 0, - 'current_batch_size' => $batch_processor->get_default_batch_size(), - 'last_error' => null, - 'recent_failures' => 0, - 'batch_first_failure' => null, - 'batch_last_failure' => null, - ) + $defaults = array( + 'total_time_spent' => 0, + 'current_batch_size' => $batch_processor->get_default_batch_size(), + 'last_error' => null, + 'recent_failures' => 0, + 'batch_first_failure' => null, + 'batch_last_failure' => null, ); + + $process_details = get_option( $this->get_processor_state_option_name( $batch_processor ) ); + $process_details = wp_parse_args( is_array( $process_details ) ? $process_details : array(), $defaults ); + + return $process_details; } /** @@ -349,7 +351,15 @@ class BatchProcessingController { * @return array List (of string) of the class names of the enqueued processors. */ public function get_enqueued_processors(): array { - return get_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array() ); + $enqueued_processors = get_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array() ); + + if ( ! is_array( $enqueued_processors ) ) { + $this->logger->error( 'Could not fetch list of processors. Clearing up queue.', array( 'source' => 'batch-processing' ) ); + delete_option( self::ENQUEUED_PROCESSORS_OPTION_NAME ); + $enqueued_processors = array(); + } + + return $enqueued_processors; } /** diff --git a/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonAdminBarBadge.php b/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonAdminBarBadge.php new file mode 100644 index 00000000000..0b65041e322 --- /dev/null +++ b/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonAdminBarBadge.php @@ -0,0 +1,108 @@ + __( 'Coming soon', 'woocommerce' ), + 'store-coming-soon' => __( 'Store coming soon', 'woocommerce' ), + 'live' => __( 'Live', 'woocommerce' ), + ); + + if ( get_option( 'woocommerce_coming_soon' ) === 'yes' ) { + if ( get_option( 'woocommerce_store_pages_only' ) === 'yes' ) { + $key = 'store-coming-soon'; + } else { + $key = 'coming-soon'; + } + } else { + $key = 'live'; + } + + $args = array( + 'id' => 'woocommerce-site-visibility-badge', + 'title' => $labels[ $key ], + 'href' => admin_url( 'admin.php?page=wc-settings&tab=site-visibility' ), + 'meta' => array( + 'class' => 'woocommerce-site-status-badge-' . $key, + ), + ); + $wp_admin_bar->add_node( $args ); + } + + /** + * Output CSS for site visibility badge. + * + * @internal + */ + public function output_css() { + if ( is_admin_bar_showing() ) { + echo ''; + } + } +} diff --git a/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php b/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php index d80f6a4c94a..d72074780f1 100644 --- a/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php +++ b/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php @@ -2,10 +2,13 @@ namespace Automattic\WooCommerce\Internal\ComingSoon; use Automattic\WooCommerce\Admin\Features\Features; +use Automattic\WooCommerce\Blocks\BlockTemplatesController; +use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry; +use Automattic\WooCommerce\Blocks\Package as BlocksPackage; /** * Handles the template_include hook to determine whether the current page needs - * to be replaced with a comiing soon screen. + * to be replaced with a coming soon screen. */ class ComingSoonRequestHandler { @@ -48,13 +51,21 @@ class ComingSoonRequestHandler { // A coming soon page needs to be displayed. Don't cache this response. nocache_headers(); + $is_fse_theme = wc_current_theme_is_fse_theme(); + $is_store_coming_soon = $this->coming_soon_helper->is_store_coming_soon(); + + if ( ! $is_fse_theme && ! current_theme_supports( 'block-template-parts' ) ) { + // Initialize block templates for use in classic theme. + BlocksPackage::init(); + $container = BlocksPackage::container(); + $container->get( BlockTemplatesRegistry::class )->init(); + $container->get( BlockTemplatesController::class )->init(); + } + add_theme_support( 'block-templates' ); $coming_soon_template = get_query_template( 'coming-soon' ); - $is_fse_theme = wc_current_theme_is_fse_theme(); - $is_store_coming_soon = $this->coming_soon_helper->is_store_coming_soon(); - if ( ! $is_fse_theme && $is_store_coming_soon ) { get_header(); } @@ -66,7 +77,9 @@ class ComingSoonRequestHandler { } ); - include $coming_soon_template; + if ( ! empty( $coming_soon_template ) && file_exists( $coming_soon_template ) ) { + include $coming_soon_template; + } if ( ! $is_fse_theme && $is_store_coming_soon ) { get_footer(); @@ -100,7 +113,7 @@ class ComingSoonRequestHandler { return false; } - // Do not show coming soon on 404 pages when restrict to store pages only. + // Do not show coming soon on 404 pages when applied to store pages only. if ( $this->coming_soon_helper->is_store_coming_soon() && is_404() ) { return false; } @@ -185,7 +198,7 @@ class ComingSoonRequestHandler { foreach ( $fonts_to_add as $font_to_add ) { $found = false; foreach ( $font_data as $font ) { - if ( $font['name'] === $font_to_add['name'] ) { + if ( isset( $font['name'] ) && $font['name'] === $font_to_add['name'] ) { $found = true; break; } diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php b/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php index 2cdc657d408..ea472d9af76 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php @@ -16,7 +16,7 @@ use Automattic\WooCommerce\Proxies\LegacyProxy; defined( 'ABSPATH' ) || exit; /** - * This class handles the database structure creation and the data synchronization for the custom orders tables. Its responsibilites are: + * This class handles the database structure creation and the data synchronization for the custom orders tables. Its responsibilities are: * * - Providing entry points for creating and deleting the required database tables. * - Synchronizing changes between the custom orders tables and the posts table whenever changes in orders happen. diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/LegacyDataHandler.php b/plugins/woocommerce/src/Internal/DataStores/Orders/LegacyDataHandler.php index d1987a71435..79eac797dfe 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/LegacyDataHandler.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/LegacyDataHandler.php @@ -322,8 +322,16 @@ class LegacyDataHandler { }; $update_data_store_func->call( $order, $data_store ); - // Read order. - $data_store->read( $order ); + // Read order (without triggering sync) -- we create our own callback instead of using `__return_false` to + // prevent `remove_filter()` from removing it in cases where it was already hooked by 3rd party code. + $prevent_sync_on_read = fn() => false; + + add_filter( 'woocommerce_hpos_enable_sync_on_read', $prevent_sync_on_read, 999 ); + try { + $data_store->read( $order ); + } finally { + remove_filter( 'woocommerce_hpos_enable_sync_on_read', $prevent_sync_on_read, 999 ); + } return $order; } diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php index c485bd15889..9602bd4f6b7 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php @@ -98,7 +98,7 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements ); /** - * Meta keys that are considered ephemereal and do not trigger a full save (updating modified date) when changed. + * Meta keys that are considered ephemeral and do not trigger a full save (updating modified date) when changed. * * @var string[] */ @@ -2586,7 +2586,7 @@ FROM $order_meta_table * @param \WC_Order $order Order object. */ public function update( &$order ) { - $previous_status = ArrayUtil::get_value_or_default( $order->get_data(), 'status' ); + $previous_status = ArrayUtil::get_value_or_default( $order->get_data(), 'status', 'new' ); // Before updating, ensure date paid is set if missing. if ( @@ -2621,8 +2621,15 @@ FROM $order_meta_table $order->apply_changes(); $this->clear_caches( $order ); - // For backwards compatibility, moving a draft order to a valid status triggers the 'woocommerce_new_order' hook. - if ( ! empty( $changes['status'] ) && in_array( $previous_status, array( 'new', 'auto-draft', 'draft', 'checkout-draft' ), true ) ) { + $draft_statuses = array( 'new', 'auto-draft', 'draft', 'checkout-draft' ); + + // For backwards compatibility, this hook should be fired only if the new status is not one of the draft statuses and the previous status was one of the draft statuses. + if ( + ! empty( $changes['status'] ) + && $changes['status'] !== $previous_status + && ! in_array( $changes['status'], $draft_statuses, true ) + && in_array( $previous_status, $draft_statuses, true ) + ) { do_action( 'woocommerce_new_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment return; } @@ -2637,7 +2644,7 @@ FROM $order_meta_table } /** - * Proxy to udpating order meta. Here for backward compatibility reasons. + * Proxy to updating order meta. Here for backward compatibility reasons. * * @param \WC_Order $order Order object. * diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ComingSoonServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ComingSoonServiceProvider.php index ba544bfaba0..10dc40ac8aa 100644 --- a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ComingSoonServiceProvider.php +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ComingSoonServiceProvider.php @@ -3,6 +3,7 @@ namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders; use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider; +use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonAdminBarBadge; use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonCacheInvalidator; use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonRequestHandler; use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper; @@ -18,6 +19,7 @@ class ComingSoonServiceProvider extends AbstractServiceProvider { * @var array */ protected $provides = array( + ComingSoonAdminBarBadge::class, ComingSoonCacheInvalidator::class, ComingSoonHelper::class, ComingSoonRequestHandler::class, @@ -27,6 +29,7 @@ class ComingSoonServiceProvider extends AbstractServiceProvider { * Register the classes. */ public function register() { + $this->add( ComingSoonAdminBarBadge::class ); $this->add( ComingSoonCacheInvalidator::class ); $this->add( ComingSoonHelper::class ); $this->add( ComingSoonRequestHandler::class )->addArgument( ComingSoonHelper::class ); diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/LoggingServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/LoggingServiceProvider.php index 58946dac50c..89f15221230 100644 --- a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/LoggingServiceProvider.php +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/LoggingServiceProvider.php @@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders; use Automattic\WooCommerce\Internal\Admin\Logging\{ PageController, Settings }; use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\FileController; use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider; +use Automattic\WooCommerce\Internal\Logging\RemoteLogger; /** * LoggingServiceProvider class. @@ -19,6 +20,7 @@ class LoggingServiceProvider extends AbstractServiceProvider { FileController::class, PageController::class, Settings::class, + RemoteLogger::class, ); /** @@ -37,5 +39,7 @@ class LoggingServiceProvider extends AbstractServiceProvider { ); $this->share( Settings::class ); + + $this->share( RemoteLogger::class ); } } diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/StatsServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/StatsServiceProvider.php new file mode 100644 index 00000000000..793fdf9418b --- /dev/null +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/StatsServiceProvider.php @@ -0,0 +1,33 @@ +add( McStats::class ); + } +} diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php index c7b33c14e36..e777fc63fb8 100644 --- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php +++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php @@ -6,7 +6,6 @@ namespace Automattic\WooCommerce\Internal\Features; use Automattic\WooCommerce\Internal\Admin\Analytics; -use Automattic\WooCommerce\Admin\Features\Navigation\Init; use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController; use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; use Automattic\WooCommerce\Proxies\LegacyProxy; @@ -174,17 +173,6 @@ class FeaturesController { 'disable_ui' => false, 'is_legacy' => true, ), - 'new_navigation' => array( - 'name' => __( 'Navigation', 'woocommerce' ), - 'description' => __( - 'Add the new WooCommerce navigation experience to the dashboard', - 'woocommerce' - ), - 'option_key' => Init::TOGGLE_OPTION_NAME, - 'is_experimental' => false, - 'disable_ui' => false, - 'is_legacy' => true, - ), 'product_block_editor' => array( 'name' => __( 'New product editor', 'woocommerce' ), 'description' => __( 'Try the new product editor (Beta)', 'woocommerce' ), @@ -247,6 +235,17 @@ class FeaturesController { 'is_legacy' => true, 'option_key' => CustomOrdersTableController::HPOS_FTS_INDEX_OPTION, ), + 'remote_logging' => array( + 'name' => __( 'Remote Logging', 'woocommerce' ), + 'description' => __( + 'Enable this feature to log errors and related data to Automattic servers for debugging purposes and to improve WooCommerce', + 'woocommerce' + ), + 'enabled_by_default' => true, + 'disable_ui' => true, + 'is_legacy' => false, + 'is_experimental' => true, + ), ); foreach ( $legacy_features as $slug => $definition ) { diff --git a/plugins/woocommerce/src/Internal/Features/ProductBlockEditor/ProductTemplates/ProductVariationTemplate.php b/plugins/woocommerce/src/Internal/Features/ProductBlockEditor/ProductTemplates/ProductVariationTemplate.php index 665ef1be5e9..faedcda8ccc 100644 --- a/plugins/woocommerce/src/Internal/Features/ProductBlockEditor/ProductTemplates/ProductVariationTemplate.php +++ b/plugins/woocommerce/src/Internal/Features/ProductBlockEditor/ProductTemplates/ProductVariationTemplate.php @@ -339,8 +339,13 @@ class ProductVariationTemplate extends AbstractProductFormTemplate implements Pr 'order' => 20, 'attributes' => array( 'property' => 'global_unique_id', - 'label' => __( 'GTIN, UPC, EAN or ISBN', 'woocommerce' ), + // translators: %1$s GTIN %2$s UPC %3$s EAN %4$s ISBN. + 'label' => sprintf( __( '%1$s, %2$s, %3$s, or %4$s', 'woocommerce' ), '' . esc_html__( 'GTIN', 'woocommerce' ) . '', '' . esc_html__( 'UPC', 'woocommerce' ) . '', '' . esc_html__( 'EAN', 'woocommerce' ) . '', '' . esc_html__( 'ISBN', 'woocommerce' ) . '' ), 'tooltip' => __( 'Enter a barcode or any other identifier unique to this product. It can help you list this product on other channels or marketplaces.', 'woocommerce' ), + 'pattern' => array( + 'value' => '[0-9\-]*', + 'message' => __( 'Please enter only numbers and hyphens (-).', 'woocommerce' ), + ), ), ) ); diff --git a/plugins/woocommerce/src/Internal/Features/ProductBlockEditor/ProductTemplates/SimpleProductTemplate.php b/plugins/woocommerce/src/Internal/Features/ProductBlockEditor/ProductTemplates/SimpleProductTemplate.php index 18a36daa681..b87b8f87499 100644 --- a/plugins/woocommerce/src/Internal/Features/ProductBlockEditor/ProductTemplates/SimpleProductTemplate.php +++ b/plugins/woocommerce/src/Internal/Features/ProductBlockEditor/ProductTemplates/SimpleProductTemplate.php @@ -765,8 +765,13 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ 'order' => 20, 'attributes' => array( 'property' => 'global_unique_id', - 'label' => __( 'GTIN, UPC, EAN or ISBN', 'woocommerce' ), + // translators: %1$s GTIN %2$s UPC %3$s EAN %4$s ISBN. + 'label' => sprintf( __( '%1$s, %2$s, %3$s, or %4$s', 'woocommerce' ), '' . esc_html__( 'GTIN', 'woocommerce' ) . '', '' . esc_html__( 'UPC', 'woocommerce' ) . '', '' . esc_html__( 'EAN', 'woocommerce' ) . '', '' . esc_html__( 'ISBN', 'woocommerce' ) . '' ), 'tooltip' => __( 'Enter a barcode or any other identifier unique to this product. It can help you list this product on other channels or marketplaces.', 'woocommerce' ), + 'pattern' => array( + 'value' => '[0-9\-]*', + 'message' => __( 'Please enter only numbers and hyphens (-).', 'woocommerce' ), + ), ), 'disableConditions' => array( array( diff --git a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php new file mode 100644 index 00000000000..01e71d40e62 --- /dev/null +++ b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php @@ -0,0 +1,438 @@ +should_handle( $level, $message, $context ) ) { + return false; + } + + return $this->log( $level, $message, $context ); + } + + /** + * Get formatted log data to be sent to the remote logging service. + * + * This method formats the log data by sanitizing the message, adding default fields, and including additional context + * such as backtrace, tags, and extra attributes. It also integrates with WC_Tracks to include blog and store details. + * The formatted log data is then filtered before being sent to the remote logging service. + * + * @param string $level Log level (e.g., 'error', 'warning', 'info'). + * @param string $message Log message to be recorded. + * @param array $context Optional. Additional information for log handlers, such as 'backtrace', 'tags', 'extra', and 'error'. + * + * @return array Formatted log data ready to be sent to the remote logging service. + */ + public function get_formatted_log( $level, $message, $context = array() ) { + $log_data = array( + // Default fields. + 'feature' => 'woocommerce_core', + 'severity' => $level, + 'message' => $this->sanitize( $message ), + 'host' => wp_parse_url( home_url(), PHP_URL_HOST ), + 'tags' => array( 'woocommerce', 'php' ), + 'properties' => array( + 'wc_version' => WC()->version, + 'php_version' => phpversion(), + 'wp_version' => get_bloginfo( 'version' ), + 'request_uri' => filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ), + ), + ); + + if ( isset( $context['backtrace'] ) ) { + if ( is_array( $context['backtrace'] ) || is_string( $context['backtrace'] ) ) { + $log_data['trace'] = $this->sanitize_trace( $context['backtrace'] ); + } elseif ( true === $context['backtrace'] ) { + $log_data['trace'] = $this->sanitize_trace( self::get_backtrace() ); + } + unset( $context['backtrace'] ); + } + + if ( isset( $context['tags'] ) && is_array( $context['tags'] ) ) { + $log_data['tags'] = array_merge( $log_data['tags'], $context['tags'] ); + unset( $context['tags'] ); + } + + if ( class_exists( '\WC_Tracks' ) ) { + $user = wp_get_current_user(); + $blog_details = \WC_Tracks::get_blog_details( $user->ID ); + + if ( is_numeric( $blog_details['blog_id'] ) && $blog_details['blog_id'] > 0 ) { + $log_data['blog_id'] = $blog_details['blog_id']; + } + + if ( ! empty( $blog_details['store_id'] ) ) { + $log_data['properties']['store_id'] = $blog_details['store_id']; + } + } + + if ( isset( $context['error'] ) && is_array( $context['error'] ) && ! empty( $context['error']['file'] ) ) { + $context['error']['file'] = $this->sanitize( $context['error']['file'] ); + } + + $extra_attrs = $context['extra'] ?? array(); + unset( $context['extra'] ); + // Merge the extra attributes with the remaining context since we can't send arbitrary fields to Logstash. + $log_data['extra'] = array_merge( $extra_attrs, $context ); + + /** + * Filters the formatted log data before sending it to the remote logging service. + * Returning a non-array value will prevent the log from being sent. + * + * @since 9.2.0 + * + * @param array $log_data The formatted log data. + * @param string $level The log level (e.g., 'error', 'warning'). + * @param string $message The log message. + * @param array $context The original context array. + * + * @return array The filtered log data. + */ + return apply_filters( 'woocommerce_remote_logger_formatted_log_data', $log_data, $level, $message, $context ); + } + + /** + * Determines if remote logging is allowed based on the following conditions: + * + * 1. The feature flag for remote error logging is enabled. + * 2. The user has opted into tracking/logging. + * 3. The store is allowed to log based on the variant assignment percentage. + * 4. The current WooCommerce version is the latest so we don't log errors that might have been fixed in a newer version. + * + * @return bool + */ + public function is_remote_logging_allowed() { + if ( ! FeaturesUtil::feature_is_enabled( 'remote_logging' ) ) { + return false; + } + + if ( ! \WC_Site_Tracking::is_tracking_enabled() ) { + return false; + } + + if ( ! $this->is_variant_assignment_allowed() ) { + return false; + } + + if ( ! $this->is_latest_woocommerce_version() ) { + return false; + } + + return true; + } + + /** + * Determine whether to handle or ignore log. + * + * @param string $level emergency|alert|critical|error|warning|notice|info|debug. + * @param string $message Log message to be recorded. + * @param array $context Additional information for log handlers. + * + * @return bool True if the log should be handled. + */ + protected function should_handle( $level, $message, $context ) { + if ( ! $this->is_remote_logging_allowed() ) { + return false; + } + // Ignore logs that are less severe than critical. This is temporary to prevent sending too many logs to the remote logging service. We can consider remove this if the remote logging service can handle more logs. + if ( WC_Log_Levels::get_level_severity( $level ) < WC_Log_Levels::get_level_severity( WC_Log_Levels::CRITICAL ) ) { + return false; + } + + if ( $this->is_third_party_error( (string) $message, (array) $context ) ) { + return false; + } + + if ( WC_Rate_Limiter::retried_too_soon( self::RATE_LIMIT_ID ) ) { + error_log( 'Remote logging throttled.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return false; + } + + return true; + } + + + /** + * Send the log to the remote logging service. + * + * @param string $level Log level (e.g., 'error', 'warning', 'info'). + * @param string $message Log message to be recorded. + * @param array $context Optional. Additional information for log handlers, such as 'backtrace', 'tags', 'extra', and 'error'. + * + * @throws \Exception If the remote logging fails. The error is caught and logged locally. + * @return bool + */ + private function log( $level, $message, $context ) { + try { + $log_data = $this->get_formatted_log( $level, $message, $context ); + + // Ensure the log data is valid. + if ( ! is_array( $log_data ) || empty( $log_data['message'] ) || empty( $log_data['feature'] ) ) { + return false; + } + + $body = array( + 'params' => wp_json_encode( $log_data ), + ); + + WC_Rate_Limiter::set_rate_limit( self::RATE_LIMIT_ID, self::RATE_LIMIT_DELAY ); + + if ( $this->is_dev_or_local_environment() ) { + return false; + } + + $response = wp_safe_remote_post( + self::LOG_ENDPOINT, + array( + 'body' => wp_json_encode( $body ), + 'timeout' => 2, + 'headers' => array( + 'Content-Type' => 'application/json', + ), + 'blocking' => false, + ) + ); + + if ( is_wp_error( $response ) ) { + throw new \Exception( $response->get_error_message() ); + } + + return true; + } catch ( \Exception $e ) { + // Log the error locally if the remote logging fails. + error_log( 'Remote logging failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return false; + } + } + + /** + * Check if the store is allowed to log based on the variant assignment percentage. + * + * @return bool + */ + private function is_variant_assignment_allowed() { + $assignment = get_option( 'woocommerce_remote_variant_assignment', 0 ); + return ( $assignment <= 12 ); // Considering 10% of the 0-120 range. + } + + /** + * Check if the current WooCommerce version is the latest. + * + * @return bool + */ + private function is_latest_woocommerce_version() { + $latest_wc_version = $this->fetch_latest_woocommerce_version(); + + if ( is_null( $latest_wc_version ) ) { + return false; + } + + return version_compare( WC()->version, $latest_wc_version, '>=' ); + } + + /** + * Check if the error exclusively contains third-party stack frames for fatal-errors source context. + * + * @param string $message The error message. + * @param array $context The error context. + * + * @return bool + */ + protected function is_third_party_error( string $message, array $context ): bool { + // Only check for fatal-errors source context. + if ( ! isset( $context['source'] ) || 'fatal-errors' !== $context['source'] ) { + return false; + } + + // If backtrace is not available, we can't determine if the error is third-party. Log it for further investigation. + if ( ! isset( $context['backtrace'] ) || ! is_array( $context['backtrace'] ) ) { + return false; + } + + $wc_plugin_dir = StringUtil::normalize_local_path_slashes( WC_ABSPATH ); + + // Check if the error message contains the WooCommerce plugin directory. + if ( str_contains( $message, $wc_plugin_dir ) ) { + return false; + } + + // Check if the backtrace contains the WooCommerce plugin directory. + foreach ( $context['backtrace'] as $trace ) { + if ( is_string( $trace ) && str_contains( $trace, $wc_plugin_dir ) ) { + return false; + } + + if ( is_array( $trace ) && isset( $trace['file'] ) && str_contains( $trace['file'], $wc_plugin_dir ) ) { + return false; + } + } + + /** + * Filter to allow other plugins to overwrite the result of the third-party error check for remote logging. + * + * @since 9.2.0 + * + * @param bool $is_third_party_error The result of the third-party error check. + * @param string $message The error message. + * @param array $context The error context. + */ + return apply_filters( 'woocommerce_remote_logging_is_third_party_error', true, $message, $context ); + } + + /** + * Fetch the latest WooCommerce version using the WordPress API and cache it. + * + * @return string|null + */ + private function fetch_latest_woocommerce_version() { + $cached_version = get_transient( self::WC_LATEST_VERSION_TRANSIENT ); + if ( $cached_version ) { + return $cached_version; + } + + $retry_count = get_transient( self::FETCH_LATEST_VERSION_RETRY ); + if ( false === $retry_count || ! is_numeric( $retry_count ) ) { + $retry_count = 0; + } + + if ( $retry_count >= 3 ) { + return null; + } + + if ( ! function_exists( 'plugins_api' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + } + // Fetch the latest version from the WordPress API. + $plugin_info = plugins_api( 'plugin_information', array( 'slug' => 'woocommerce' ) ); + + if ( is_wp_error( $plugin_info ) ) { + ++$retry_count; + set_transient( self::FETCH_LATEST_VERSION_RETRY, $retry_count, HOUR_IN_SECONDS ); + return null; + } + + if ( ! empty( $plugin_info->version ) ) { + $latest_version = $plugin_info->version; + set_transient( self::WC_LATEST_VERSION_TRANSIENT, $latest_version, WEEK_IN_SECONDS ); + delete_transient( self::FETCH_LATEST_VERSION_RETRY ); + return $latest_version; + } + + return null; + } + + /** + * Sanitize the content to exclude sensitive data. + * + * The trace is sanitized by: + * + * 1. Remove the absolute path to the WooCommerce plugin directory. + * 2. Remove the absolute path to the WordPress root directory. + * + * For example, the trace: + * + * /var/www/html/wp-content/plugins/woocommerce/includes/class-wc-remote-logger.php on line 123 + * will be sanitized to: **\/woocommerce/includes/class-wc-remote-logger.php on line 123 + * + * @param string $message The message to sanitize. + * @return string The sanitized message. + */ + private function sanitize( $message ) { + if ( ! is_string( $message ) ) { + return $message; + } + + $wc_path = StringUtil::normalize_local_path_slashes( WC_ABSPATH ); + $wp_path = StringUtil::normalize_local_path_slashes( ABSPATH ); + + $sanitized = str_replace( + array( $wc_path, $wp_path ), + array( '**/' . dirname( WC_PLUGIN_BASENAME ) . '/', '**/' ), + $message + ); + + return $sanitized; + } + + /** + * Sanitize the error trace to exclude sensitive data. + * + * @param array|string $trace The error trace. + * @return string The sanitized trace. + */ + private function sanitize_trace( $trace ): string { + if ( is_string( $trace ) ) { + return $this->sanitize( $trace ); + } + + if ( ! is_array( $trace ) ) { + return ''; + } + + $sanitized_trace = array_map( + function ( $trace_item ) { + if ( is_array( $trace_item ) && isset( $trace_item['file'] ) ) { + $trace_item['file'] = $this->sanitize( $trace_item['file'] ); + return $trace_item; + } + + return $this->sanitize( $trace_item ); + }, + $trace + ); + + $is_array_by_file = isset( $sanitized_trace[0]['file'] ); + if ( $is_array_by_file ) { + return wc_print_r( $sanitized_trace, true ); + } + + return implode( "\n", $sanitized_trace ); + } + + /** + * Check if the current environment is development or local. + * + * Creates a helper method so we can easily mock this in tests. + * + * @return bool + */ + protected function is_dev_or_local_environment() { + return in_array( wp_get_environment_type(), array( 'development', 'local' ), true ); + } +} diff --git a/plugins/woocommerce/src/Internal/McStats.php b/plugins/woocommerce/src/Internal/McStats.php new file mode 100644 index 00000000000..89320e1ee6a --- /dev/null +++ b/plugins/woocommerce/src/Internal/McStats.php @@ -0,0 +1,63 @@ +get_current_stats(); + if ( isset( $stats[ $group_name ] ) && ! empty( $stats[ $group_name ] ) ) { + return array( "x_woocommerce-{$group_name}" => implode( ',', $stats[ $group_name ] ) ); + } + return array(); + } + + /** + * Outputs the tracking pixels for the current stats and empty the stored stats from the object + * + * @return void + */ + public function do_stats() { + if ( ! \WC_Site_Tracking::is_tracking_enabled() ) { + return; + } + + parent::do_stats(); + } + + /** + * Runs stats code for a one-off, server-side. + * + * @param string $url string The URL to be pinged. Should include `x_woocommerce-{$group}={$stats}` or whatever we want to store. + * + * @return bool If it worked. + */ + public function do_server_side_stat( $url ) { + if ( ! \WC_Site_Tracking::is_tracking_enabled() ) { + return false; + } + + return parent::do_server_side_stat( $url ); + } +} diff --git a/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php b/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php index e9ba76b3b63..cf0b6325751 100644 --- a/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php +++ b/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php @@ -431,9 +431,14 @@ class LookupDataStore { * Create all the necessary lookup data for a given variation. * * @param \WC_Product_Variation $variation The variation to create entries for. + * @throws \Exception Can't retrieve the details of the parent product. */ private function create_data_for_variation( \WC_Product_Variation $variation ) { $main_product = WC()->call_function( 'wc_get_product', $variation->get_parent_id() ); + if ( false === $main_product ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + throw new \Exception( "The product is a variation, and the retrieval of data for the parent product (id {$variation->get_parent_id()}) failed." ); + } $product_attributes_data = $this->get_attribute_taxonomies( $main_product ); $variation_attributes_data = array_filter( diff --git a/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Synchronize.php b/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Synchronize.php index e643f439336..5d214b54335 100644 --- a/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Synchronize.php +++ b/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Synchronize.php @@ -130,7 +130,7 @@ class Synchronize { } /** - * Runs the syncronization task. + * Runs the synchronization task. */ public function run() { $products = $this->get_next_set_of_downloadable_products(); diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php b/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php index 5bc479e3fd6..890856e062c 100644 --- a/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php @@ -198,8 +198,34 @@ class ReceiptRenderingEngine { */ $data['css'] = apply_filters( 'woocommerce_printable_order_receipt_css', $css, $order ); + $default_template_path = __DIR__ . '/Templates/order-receipt.php'; + + /** + * Filter the order receipt template path. + * + * @since 9.2.0 + * @hook wc_get_template + * @param string $template The template path. + * @param string $template_name The template name. + * @param array $args The available data for the template. + * @param string $template_path The template path. + * @param string $default_path The default template path. + */ + $template_path = apply_filters( + 'wc_get_template', + $default_template_path, + 'ReceiptRendering/order-receipt.php', + $data, + $default_template_path, + $default_template_path + ); + + if ( ! file_exists( $template_path ) ) { + $template_path = $default_template_path; + } + ob_start(); - include __DIR__ . '/Templates/order-receipt.php'; + include $template_path; $rendered_template = ob_get_contents(); ob_end_clean(); diff --git a/plugins/woocommerce/src/Internal/Traits/OrderAttributionMeta.php b/plugins/woocommerce/src/Internal/Traits/OrderAttributionMeta.php index 1bd7ea2cd4f..4a09f58c82b 100644 --- a/plugins/woocommerce/src/Internal/Traits/OrderAttributionMeta.php +++ b/plugins/woocommerce/src/Internal/Traits/OrderAttributionMeta.php @@ -19,7 +19,7 @@ use WP_Post; trait OrderAttributionMeta { /** - * The default fields and their sourcebuster accesors, + * The default fields and their sourcebuster accessors, * to show in the source data metabox. * * @var string[] diff --git a/plugins/woocommerce/src/Internal/Utilities/PluginInstaller.php b/plugins/woocommerce/src/Internal/Utilities/PluginInstaller.php index c1985ca81d3..c3acb6ba4a9 100644 --- a/plugins/woocommerce/src/Internal/Utilities/PluginInstaller.php +++ b/plugins/woocommerce/src/Internal/Utilities/PluginInstaller.php @@ -4,7 +4,7 @@ namespace Automattic\WooCommerce\Internal\Utilities; use Automattic\WooCommerce\Internal\RegisterHooksInterface; use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; -use Automattic\WooCommerce\Utilities\StringUtil; +use Automattic\WooCommerce\Utilities\{ PluginUtil, StringUtil }; /** * This class allows installing a plugin programmatically. @@ -206,7 +206,14 @@ class PluginInstaller implements RegisterHooksInterface { * @return bool True if WooCommerce is installed and active in the current blog, false otherwise. */ private static function woocommerce_is_active_in_current_site(): bool { - return ! empty( array_filter( wp_get_active_and_valid_plugins(), fn( $plugin ) => substr_compare( $plugin, '/woocommerce.php', -strlen( '/woocommerce.php' ) ) === 0 ) ); + $active_valid_plugins = wc_get_container()->get( PluginUtil::class )->get_all_active_valid_plugins(); + + return ! empty( + array_filter( + $active_valid_plugins, + fn( $plugin ) => substr_compare( $plugin, '/woocommerce.php', -strlen( '/woocommerce.php' ) ) === 0 + ) + ); } /** diff --git a/plugins/woocommerce/src/Internal/Utilities/URL.php b/plugins/woocommerce/src/Internal/Utilities/URL.php index 60963b09ffa..dea63a76cc8 100644 --- a/plugins/woocommerce/src/Internal/Utilities/URL.php +++ b/plugins/woocommerce/src/Internal/Utilities/URL.php @@ -281,7 +281,7 @@ class URL { $double_dots = array_keys( $this->path_parts, '..', true ); $max_dot_index = max( array_merge( $single_dots, $double_dots ) ); - // Prepend the required number of traversals and discard unnessary trailing segments. + // Prepend the required number of traversals and discard unnecessary trailing segments. $last_traversal = $max_dot_index + ( $this->is_non_root_directory ? 1 : 0 ); $parent_path = str_repeat( '../', $level ) . join( '/', array_slice( $this->path_parts, 0, $last_traversal ) ); } elseif ( $parent_path_parts_to_keep < 0 ) { diff --git a/plugins/woocommerce/src/Packages.php b/plugins/woocommerce/src/Packages.php index 46117f7d74d..b9255a4580c 100644 --- a/plugins/woocommerce/src/Packages.php +++ b/plugins/woocommerce/src/Packages.php @@ -32,7 +32,7 @@ class Packages { /** * Array of package names and their main package classes. * - * One a package has been merged into WooCommerce Core it should be moved fron the package list and placed in + * One a package has been merged into WooCommerce Core it should be moved from the package list and placed in * this list. This will ensure that the feature plugin is disabled as well as provide the class to handle * initialization for the now-merged feature plugin. * diff --git a/plugins/woocommerce/src/StoreApi/Authentication.php b/plugins/woocommerce/src/StoreApi/Authentication.php index 463a82d23bb..0e5078887e9 100644 --- a/plugins/woocommerce/src/StoreApi/Authentication.php +++ b/plugins/woocommerce/src/StoreApi/Authentication.php @@ -33,7 +33,6 @@ class Authentication { public function allowed_cors_headers( $allowed_headers ) { $allowed_headers[] = 'Cart-Token'; $allowed_headers[] = 'Nonce'; - $allowed_headers[] = 'X-WC-Store-API-Nonce'; return $allowed_headers; } diff --git a/plugins/woocommerce/src/StoreApi/Formatters/MoneyFormatter.php b/plugins/woocommerce/src/StoreApi/Formatters/MoneyFormatter.php index e4a61272228..c6ce0f3637c 100644 --- a/plugins/woocommerce/src/StoreApi/Formatters/MoneyFormatter.php +++ b/plugins/woocommerce/src/StoreApi/Formatters/MoneyFormatter.php @@ -8,13 +8,24 @@ namespace Automattic\WooCommerce\StoreApi\Formatters; */ class MoneyFormatter implements FormatterInterface { /** - * Format a given value and return the result. + * Format a given price value and return the result as a string without decimals. * - * @param mixed $value Value to format. - * @param array $options Options that influence the formatting. - * @return mixed + * @param int|float|string $value Value to format. Int is allowed, as it may also represent a valid price. + * @param array $options Options that influence the formatting. + * @return string */ public function format( $value, array $options = [] ) { + + if ( ! is_int( $value ) && ! is_string( $value ) && ! is_float( $value ) ) { + wc_doing_it_wrong( + __FUNCTION__, + 'Function expects a $value arg of type INT, STRING or FLOAT.', + '9.2' + ); + + return ''; + } + $options = wp_parse_args( $options, [ @@ -23,12 +34,20 @@ class MoneyFormatter implements FormatterInterface { ] ); - return (string) intval( - round( - ( (float) wc_format_decimal( $value ) ) * ( 10 ** absint( $options['decimals'] ) ), - 0, - absint( $options['rounding_mode'] ) - ) - ); + // Ensure rounding mode is valid. + $rounding_modes = [ PHP_ROUND_HALF_UP, PHP_ROUND_HALF_DOWN, PHP_ROUND_HALF_EVEN, PHP_ROUND_HALF_ODD ]; + $options['rounding_mode'] = absint( $options['rounding_mode'] ); + if ( ! in_array( $options['rounding_mode'], $rounding_modes, true ) ) { + $options['rounding_mode'] = PHP_ROUND_HALF_UP; + } + + $value = floatval( $value ); + + // Remove the price decimal points for rounding purposes. + $value = $value * pow( 10, absint( $options['decimals'] ) ); + $value = round( $value, 0, $options['rounding_mode'] ); + + // This ensures returning the value as a string without decimal points ready for price parsing. + return wc_format_decimal( $value, 0, true ); } } diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/BusinessDescription.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AI/BusinessDescription.php deleted file mode 100644 index 79e5a221380..00000000000 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/BusinessDescription.php +++ /dev/null @@ -1,97 +0,0 @@ - \WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'get_response' ], - 'permission_callback' => [ Middleware::class, 'is_authorized' ], - 'args' => [ - 'business_description' => [ - 'description' => __( 'The business description for a given store.', 'woocommerce' ), - 'type' => 'string', - ], - ], - ], - 'schema' => [ $this->schema, 'get_public_item_schema' ], - 'allow_batch' => [ 'v1' => true ], - ]; - } - - /** - * Update the last business description. - * - * @param \WP_REST_Request $request Request object. - * - * @return bool|string|\WP_Error|\WP_REST_Response - */ - protected function get_route_post_response( \WP_REST_Request $request ) { - - $business_description = $request->get_param( 'business_description' ); - - if ( ! $business_description ) { - return $this->error_to_response( - new \WP_Error( - 'invalid_business_description', - __( 'Invalid business description.', 'woocommerce' ) - ) - ); - } - - update_option( 'last_business_description_with_ai_content_generated', $business_description ); - - return rest_ensure_response( - array( - 'ai_content_generated' => true, - ) - ); - } - -} diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Images.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Images.php deleted file mode 100644 index 51cf7d2abc1..00000000000 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Images.php +++ /dev/null @@ -1,130 +0,0 @@ - \WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'get_response' ], - 'permission_callback' => [ Middleware::class, 'is_authorized' ], - 'args' => [ - 'business_description' => [ - 'description' => __( 'The business description for a given store.', 'woocommerce' ), - 'type' => 'string', - ], - ], - ], - 'schema' => [ $this->schema, 'get_public_item_schema' ], - 'allow_batch' => [ 'v1' => true ], - ]; - } - - /** - * Generate Images from Pexels - * - * @param \WP_REST_Request $request Request object. - * - * @return bool|string|\WP_Error|\WP_REST_Response - */ - protected function get_route_post_response( \WP_REST_Request $request ) { - - $business_description = sanitize_text_field( wp_unslash( $request['business_description'] ) ); - - if ( empty( $business_description ) ) { - $business_description = get_option( 'woo_ai_describe_store_description' ); - } - - $last_business_description = get_option( 'last_business_description_with_ai_content_generated' ); - - if ( $last_business_description === $business_description ) { - return rest_ensure_response( - $this->prepare_item_for_response( - [ - 'ai_content_generated' => true, - 'images' => array(), - ], - $request - ) - ); - } - - $ai_connection = new Connection(); - - $site_id = $ai_connection->get_site_id(); - - if ( is_wp_error( $site_id ) ) { - return $this->error_to_response( $site_id ); - } - - $token = $ai_connection->get_jwt_token( $site_id ); - - if ( is_wp_error( $token ) ) { - return $this->error_to_response( $token ); - } - - $images = ( new Pexels() )->get_images( $ai_connection, $token, $business_description ); - - if ( is_wp_error( $images ) ) { - $images = array( - 'images' => array(), - 'search_term' => '', - ); - } - - return rest_ensure_response( - $this->prepare_item_for_response( - [ - 'ai_content_generated' => true, - 'images' => $images, - ], - $request - ) - ); - } -} diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Middleware.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Middleware.php deleted file mode 100644 index 33a2d8ece7f..00000000000 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Middleware.php +++ /dev/null @@ -1,50 +0,0 @@ -getErrorCode(), - $error->getMessage(), - array( 'status' => $error->getCode() ) - ); - } - - $allow_ai_connection = get_option( 'woocommerce_blocks_allow_ai_connection' ); - - if ( ! $allow_ai_connection ) { - try { - throw new RouteException( 'ai_connection_not_allowed', __( 'AI content generation is not allowed on this store. Update your store settings if you wish to enable this feature.', 'woocommerce' ), 403 ); - } catch ( RouteException $error ) { - return new \WP_Error( - $error->getErrorCode(), - $error->getMessage(), - array( 'status' => $error->getCode() ) - ); - } - } - - return true; - } -} diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Patterns.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Patterns.php deleted file mode 100644 index f818c7860a0..00000000000 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Patterns.php +++ /dev/null @@ -1,122 +0,0 @@ - \WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'get_response' ], - 'permission_callback' => [ Middleware::class, 'is_authorized' ], - 'args' => [ - 'business_description' => [ - 'description' => __( 'The business description for a given store.', 'woocommerce' ), - 'type' => 'string', - ], - 'images' => [ - 'description' => __( 'The images for a given store.', 'woocommerce' ), - 'type' => 'object', - ], - ], - ], - [ - 'methods' => \WP_REST_Server::DELETABLE, - 'callback' => [ $this, 'get_response' ], - 'permission_callback' => [ Middleware::class, 'is_authorized' ], - ], - 'schema' => [ $this->schema, 'get_public_item_schema' ], - 'allow_batch' => [ 'v1' => true ], - ]; - } - - /** - * Update patterns with the content and images powered by AI. - * - * @param \WP_REST_Request $request Request object. - * - * @return WP_Error|\WP_HTTP_Response|\WP_REST_Response - */ - protected function get_route_post_response( \WP_REST_Request $request ) { - $business_description = sanitize_text_field( wp_unslash( $request['business_description'] ) ); - - $ai_connection = new Connection(); - - $site_id = $ai_connection->get_site_id(); - - if ( is_wp_error( $site_id ) ) { - return $this->error_to_response( $site_id ); - } - - $token = $ai_connection->get_jwt_token( $site_id ); - - $images = $request['images']; - - try { - ( new UpdatePatterns() )->generate_content( $ai_connection, $token, $images, $business_description ); - return rest_ensure_response( array( 'ai_content_generated' => true ) ); - } catch ( WP_Error $e ) { - return $this->error_to_response( $e ); - } - } - - /** - * Remove patterns generated by AI. - * - * @param \WP_REST_Request $request Request object. - * - * @return bool|string|WP_Error|\WP_REST_Response - */ - protected function get_route_delete_response( \WP_REST_Request $request ) { - PatternsHelper::delete_patterns_ai_data_post(); - return rest_ensure_response( array( 'removed' => true ) ); - } -} diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Product.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Product.php deleted file mode 100644 index 46b76355e71..00000000000 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Product.php +++ /dev/null @@ -1,107 +0,0 @@ - \WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'get_response' ], - 'permission_callback' => [ Middleware::class, 'is_authorized' ], - 'args' => [ - 'products_information' => [ - 'description' => __( 'Data generated by AI for updating dummy products.', 'woocommerce' ), - 'type' => 'object', - ], - 'last_product' => [ - 'description' => __( 'Whether the product being updated is the last one in the loop', 'woocommerce' ), - 'type' => 'boolean', - ], - ], - ], - 'schema' => [ $this->schema, 'get_public_item_schema' ], - 'allow_batch' => [ 'v1' => true ], - ]; - } - - /** - * Update product with the content and image powered by AI. - * - * @param \WP_REST_Request $request Request object. - * - * @return bool|string|\WP_Error|\WP_REST_Response - */ - protected function get_route_post_response( \WP_REST_Request $request ) { - $product_updater = new UpdateProducts(); - $product_information = $request['products_information'] ?? array(); - - if ( empty( $product_information ) ) { - return rest_ensure_response( - array( - 'ai_content_generated' => true, - ) - ); - } - - $product_updater->update_product_content( $product_information ); - - $last_product_to_update = $request['last_product'] ?? false; - - if ( $last_product_to_update ) { - flush_rewrite_rules(); - } - - return rest_ensure_response( - array( - 'ai_content_generated' => true, - ) - ); - } - -} diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Products.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Products.php deleted file mode 100644 index 086b8a61636..00000000000 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/Products.php +++ /dev/null @@ -1,153 +0,0 @@ - \WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'get_response' ], - 'permission_callback' => [ Middleware::class, 'is_authorized' ], - 'args' => [ - 'business_description' => [ - 'description' => __( 'The business description for a given store.', 'woocommerce' ), - 'type' => 'string', - ], - 'images' => [ - 'description' => __( 'The images for a given store.', 'woocommerce' ), - 'type' => 'object', - ], - ], - ], - [ - 'methods' => \WP_REST_Server::DELETABLE, - 'callback' => [ $this, 'get_response' ], - 'permission_callback' => [ Middleware::class, 'is_authorized' ], - ], - 'schema' => [ $this->schema, 'get_public_item_schema' ], - 'allow_batch' => [ 'v1' => true ], - ]; - } - - /** - * Generate the content for the products. - * - * @param \WP_REST_Request $request Request object. - * - * @return bool|string|\WP_Error|\WP_REST_Response - */ - protected function get_route_post_response( \WP_REST_Request $request ) { - $allow_ai_connection = get_option( 'woocommerce_blocks_allow_ai_connection' ); - - if ( ! $allow_ai_connection ) { - return rest_ensure_response( - $this->error_to_response( - new \WP_Error( - 'ai_connection_not_allowed', - __( 'AI content generation is not allowed on this store. Update your store settings if you wish to enable this feature.', 'woocommerce' ) - ) - ) - ); - } - - $business_description = sanitize_text_field( wp_unslash( $request['business_description'] ) ); - - if ( empty( $business_description ) ) { - $business_description = get_option( 'woo_ai_describe_store_description' ); - } - - $ai_connection = new Connection(); - - $site_id = $ai_connection->get_site_id(); - - if ( is_wp_error( $site_id ) ) { - return $this->error_to_response( $site_id ); - } - - $token = $ai_connection->get_jwt_token( $site_id ); - - if ( is_wp_error( $token ) ) { - return $this->error_to_response( $token ); - } - - $images = $request['images']; - - $populate_products = ( new UpdateProducts() )->generate_content( $ai_connection, $token, $images, $business_description ); - - if ( is_wp_error( $populate_products ) ) { - return $this->error_to_response( $populate_products ); - } - - if ( ! isset( $populate_products['product_content'] ) ) { - return $this->error_to_response( new \WP_Error( 'product_content_not_found', __( 'Product content not found.', 'woocommerce' ) ) ); - } - - $product_content = $populate_products['product_content']; - - $item = array( - 'ai_content_generated' => true, - 'product_content' => $product_content, - ); - - return rest_ensure_response( $item ); - } - - /** - * Remove products generated by AI. - * - * @param \WP_REST_Request $request Request object. - * - * @return bool|string|\WP_Error|\WP_REST_Response - */ - protected function get_route_delete_response( \WP_REST_Request $request ) { - ( new UpdateProducts() )->reset_products_content(); - return rest_ensure_response( array( 'removed' => true ) ); - } -} diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/StoreInfo.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AI/StoreInfo.php deleted file mode 100644 index c3b28d61676..00000000000 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/StoreInfo.php +++ /dev/null @@ -1,94 +0,0 @@ - \WP_REST_Server::READABLE, - 'callback' => [ $this, 'get_response' ], - 'permission_callback' => [ Middleware::class, 'is_authorized' ], - ], - 'schema' => [ $this->schema, 'get_public_item_schema' ], - 'allow_batch' => [ 'v1' => true ], - ]; - } - - /** - * Update the store title powered by AI. - * - * @param \WP_REST_Request $request Request object. - * - * @return bool|string|\WP_Error|\WP_REST_Response - */ - protected function get_route_response( \WP_REST_Request $request ) { - $product_updater = new UpdateProducts(); - $patterns = PatternsHelper::get_patterns_ai_data_post(); - - $products = $product_updater->fetch_product_ids( 'dummy' ); - - if ( empty( $products ) && ! isset( $patterns ) ) { - return rest_ensure_response( - array( - 'is_ai_generated' => false, - ) - ); - } - - return rest_ensure_response( - array( - 'is_ai_generated' => true, - ) - ); - - } - - -} diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/StoreTitle.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AI/StoreTitle.php deleted file mode 100644 index 0c345722d92..00000000000 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/AI/StoreTitle.php +++ /dev/null @@ -1,158 +0,0 @@ - \WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'get_response' ), - 'permission_callback' => array( Middleware::class, 'is_authorized' ), - 'args' => array( - 'business_description' => array( - 'description' => __( 'The business description for a given store.', 'woocommerce' ), - 'type' => 'string', - ), - ), - ), - 'schema' => array( $this->schema, 'get_public_item_schema' ), - 'allow_batch' => array( 'v1' => true ), - ); - } - - /** - * Update the store title powered by AI. - * - * @param \WP_REST_Request $request Request object. - * - * @return bool|string|\WP_Error|\WP_REST_Response - */ - protected function get_route_post_response( \WP_REST_Request $request ) { - - $business_description = $request->get_param( 'business_description' ); - - if ( ! $business_description ) { - return $this->error_to_response( - new \WP_Error( - 'invalid_business_description', - __( 'Invalid business description.', 'woocommerce' ) - ) - ); - } - - $store_title = html_entity_decode( get_option( 'blogname' ) ); - $previous_ai_generated_title = html_entity_decode( get_option( 'ai_generated_site_title' ) ); - - if ( self::DEFAULT_TITLE === $store_title || ( ! empty( $store_title ) && $previous_ai_generated_title !== $store_title ) ) { - return rest_ensure_response( array( 'ai_content_generated' => false ) ); - } - - $ai_generated_title = $this->generate_ai_title( $business_description ); - if ( is_wp_error( $ai_generated_title ) ) { - return $this->error_to_response( $ai_generated_title ); - } - - update_option( 'ai_generated_site_title', $ai_generated_title ); - update_option( self::STORE_TITLE_OPTION_NAME, $ai_generated_title ); - - return rest_ensure_response( - array( - 'ai_content_generated' => true, - ) - ); - } - - /** - * Generate the store title powered by AI. - * - * @param string $business_description The business description for a given store. - * - * @return string|\WP_Error|\WP_REST_Response The store title generated by AI. - */ - private function generate_ai_title( $business_description ) { - $ai_connection = new Connection(); - - $site_id = $ai_connection->get_site_id(); - if ( is_wp_error( $site_id ) ) { - return $this->error_to_response( $site_id ); - } - - $token = $ai_connection->get_jwt_token( $site_id ); - if ( is_wp_error( $token ) ) { - return $this->error_to_response( $token ); - } - - $prompt = "Generate a store title for a store that has the following: '$business_description'. The length of the title should be 1 and 3 words. The result should include only the store title without any other explanation, number or punctuation marks"; - - $ai_response = $ai_connection->fetch_ai_response( $token, $prompt ); - if ( is_wp_error( $ai_response ) ) { - return $this->error_to_response( $ai_response ); - } - - if ( ! isset( $ai_response['completion'] ) ) { - return ''; - } - - return $ai_response['completion']; - } -} diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php index a760664327f..e26cb056934 100644 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php +++ b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php @@ -70,6 +70,13 @@ abstract class AbstractCartRoute extends AbstractRoute { */ protected $additional_fields_controller; + /** + * True when this route has been requested with a valid cart token. + * + * @var bool|null + */ + protected $has_cart_token = null; + /** * Constructor. * @@ -151,9 +158,6 @@ abstract class AbstractCartRoute extends AbstractRoute { $response->header( 'User-ID', get_current_user_id() ); $response->header( 'Cart-Token', $this->get_cart_token() ); - // The following headers are deprecated and should be removed in a future version. - $response->header( 'X-WC-Store-API-Nonce', $nonce ); - return $response; } @@ -163,9 +167,7 @@ abstract class AbstractCartRoute extends AbstractRoute { * @param \WP_REST_Request $request Request object. */ protected function load_cart_session( \WP_REST_Request $request ) { - $cart_token = $request->get_header( 'Cart-Token' ); - - if ( $cart_token && JsonWebToken::validate( $cart_token, $this->get_cart_token_secret() ) ) { + if ( $this->has_cart_token( $request ) ) { // Overrides the core session class. add_filter( 'woocommerce_session_handler', @@ -174,7 +176,6 @@ abstract class AbstractCartRoute extends AbstractRoute { } ); } - $this->cart_controller->load_cart(); } @@ -222,6 +223,19 @@ abstract class AbstractCartRoute extends AbstractRoute { return time() + intval( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) ); } + /** + * Checks if the request has a valid cart token. + * + * @param \WP_REST_Request $request Request object. + * @return bool + */ + protected function has_cart_token( \WP_REST_Request $request ) { + if ( is_null( $this->has_cart_token ) ) { + $this->has_cart_token = JsonWebToken::validate( $request->get_header( 'Cart-Token' ) ?? '', $this->get_cart_token_secret() ); + } + return $this->has_cart_token; + } + /** * Checks if a nonce is required for the route. * @@ -230,7 +244,7 @@ abstract class AbstractCartRoute extends AbstractRoute { * @return bool */ protected function requires_nonce( \WP_REST_Request $request ) { - return $this->is_update_request( $request ); + return $this->is_update_request( $request ) && ! $this->has_cart_token( $request ); } /** @@ -286,12 +300,6 @@ abstract class AbstractCartRoute extends AbstractRoute { if ( $request->get_header( 'Nonce' ) ) { $nonce = $request->get_header( 'Nonce' ); - } elseif ( $request->get_header( 'X-WC-Store-API-Nonce' ) ) { - $nonce = $request->get_header( 'X-WC-Store-API-Nonce' ); - - // @todo Remove handling and sending of deprecated X-WC-Store-API-Nonce Header (Blocks 7.5.0) - wc_deprecated_argument( 'X-WC-Store-API-Nonce', '7.2.0', 'Use the "Nonce" Header instead. This header will be removed after Blocks release 7.5' ); - rest_handle_deprecated_argument( 'X-WC-Store-API-Nonce', 'Use the "Nonce" Header instead. This header will be removed after Blocks release 7.5', '7.2.0' ); } /** diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php index 144bd861ad2..44dd3118b06 100644 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php +++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php @@ -128,7 +128,6 @@ class Batch extends AbstractRoute implements RouteInterface { $nonce = wp_create_nonce( 'wc_store_api' ); $response->header( 'Nonce', $nonce ); - $response->header( 'X-WC-Store-API-Nonce', $nonce ); $response->header( 'Nonce-Timestamp', time() ); $response->header( 'User-ID', get_current_user_id() ); diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php index dcc6b9e852d..243403488f5 100644 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php +++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php @@ -1,14 +1,14 @@ has_cart_token( $request ); } /** @@ -88,7 +88,7 @@ class Checkout extends AbstractCartRoute { 'permission_callback' => '__return_true', 'args' => array_merge( [ - 'payment_data' => [ + 'payment_data' => [ 'description' => __( 'Data to pass through to the payment method when processing payment.', 'woocommerce' ), 'type' => 'array', 'items' => [ @@ -103,6 +103,10 @@ class Checkout extends AbstractCartRoute { ], ], ], + 'customer_password' => [ + 'description' => __( 'Customer password for new accounts, if applicable.', 'woocommerce' ), + 'type' => 'string', + ], ], $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ) ), @@ -143,6 +147,11 @@ class Checkout extends AbstractCartRoute { if ( is_wp_error( $response ) ) { $response = $this->error_to_response( $response ); + + // If we encountered an exception, free up stock. + if ( $this->order ) { + wc_release_stock_for_order( $this->order ); + } } return $this->add_response_headers( $response ); @@ -167,6 +176,54 @@ class Checkout extends AbstractCartRoute { ); } + /** + * Validate required additional fields on request. + * + * @param \WP_REST_Request $request Request object. + * + * @throws RouteException When a required additional field is missing. + */ + public function validate_required_additional_fields( \WP_REST_Request $request ) { + $contact_fields = $this->additional_fields_controller->get_fields_for_location( 'contact' ); + $order_fields = $this->additional_fields_controller->get_fields_for_location( 'order' ); + $order_and_contact_fields = array_merge( $contact_fields, $order_fields ); + + if ( ! empty( $order_and_contact_fields ) ) { + foreach ( $order_and_contact_fields as $field_key => $order_and_contact_field ) { + if ( $order_and_contact_field['required'] && ! isset( $request['additional_fields'][ $field_key ] ) ) { + throw new RouteException( + 'woocommerce_rest_checkout_missing_required_field', + /* translators: %s: is the field label */ + esc_html( sprintf( __( 'There was a problem with the provided additional fields: %s is required', 'woocommerce' ), $order_and_contact_field['label'] ) ), + 400 + ); + } + } + } + + $address_fields = $this->additional_fields_controller->get_fields_for_location( 'address' ); + if ( ! empty( $address_fields ) ) { + foreach ( $address_fields as $field_key => $address_field ) { + if ( $address_field['required'] && ! isset( $request['billing_address'][ $field_key ] ) ) { + throw new RouteException( + 'woocommerce_rest_checkout_missing_required_field', + /* translators: %s: is the field label */ + esc_html( sprintf( __( 'There was a problem with the provided billing address: %s is required', 'woocommerce' ), $address_field['label'] ) ), + 400 + ); + } + if ( $address_field['required'] && ! isset( $request['shipping_address'][ $field_key ] ) ) { + throw new RouteException( + 'woocommerce_rest_checkout_missing_required_field', + /* translators: %s: is the field label */ + esc_html( sprintf( __( 'There was a problem with the provided shipping address: %s is required', 'woocommerce' ), $address_field['label'] ) ), + 400 + ); + } + } + } + } + /** * Process an order. * @@ -177,7 +234,6 @@ class Checkout extends AbstractCartRoute { * 5. Process Payment * * @throws RouteException On error. - * @throws InvalidStockLevelsInCartException On error. * * @param \WP_REST_Request $request Request object. * @@ -185,35 +241,67 @@ class Checkout extends AbstractCartRoute { */ protected function get_route_post_response( \WP_REST_Request $request ) { /** - * Validate items etc are allowed in the order before the order is processed. This will fix violations and tell - * the customer. + * Before triggering validation, ensure totals are current and in turn, things such as shipping costs are present. + * This is so plugins that validate other cart data (e.g. conditional shipping and payments) can access this data. + */ + $this->cart_controller->calculate_totals(); + + /** + * Validate items and fix violations before the order is processed. */ $this->cart_controller->validate_cart(); /** - * Obtain Draft Order and process request data. - * - * Note: Customer data is persisted from the request first so that OrderController::update_addresses_from_cart + * Validate additional fields on request. + */ + $this->validate_required_additional_fields( $request ); + + /** + * Persist customer session data from the request first so that OrderController::update_addresses_from_cart * uses the up to date customer address. */ $this->update_customer_from_request( $request ); - $this->create_or_update_draft_order( $request ); - $this->update_order_from_request( $request ); /** - * Process customer data. - * - * Update order with customer details, and sign up a user account as necessary. + * Create (or update) Draft Order and process request data. */ + $this->create_or_update_draft_order( $request ); + $this->update_order_from_request( $request ); $this->process_customer( $request ); /** - * Validate order. - * - * This logic ensures the order is valid before payment is attempted. + * Validate updated order before payment is attempted. */ $this->order_controller->validate_order_before_payment( $this->order ); + /** + * Reserve stock for the order. + * + * In the shortcode based checkout, when POSTing the checkout form the order would be created and fire the + * `woocommerce_checkout_order_created` action. This in turn would trigger the `wc_reserve_stock_for_order` + * function so that stock would be held pending payment. + * + * Via the block based checkout and Store API we already have a draft order, but when POSTing to the /checkout + * endpoint we do the same; reserve stock for the order to allow time to process payment. + * + * Note, stock is only "held" while the order has the status wc-checkout-draft or pending. Stock is freed when + * the order changes status, or there is an exception. + * + * @see ReserveStock::get_query_for_reserved_stock() + * + * @since 9.2 Stock is no longer held for all draft orders, nor on non-POST requests. See https://github.com/woocommerce/woocommerce/issues/44231 + * @since 9.2 Uses wc_reserve_stock_for_order() instead of using the ReserveStock class directly. + */ + try { + wc_reserve_stock_for_order( $this->order ); + } catch ( ReserveStockException $e ) { + throw new RouteException( + esc_html( $e->getErrorCode() ), + esc_html( $e->getMessage() ), + esc_html( $e->getCode() ) + ); + } + wc_do_deprecated_action( '__experimental_woocommerce_blocks_checkout_order_processed', array( @@ -392,24 +480,6 @@ class Checkout extends AbstractCartRoute { // Store order ID to session. $this->set_draft_order_id( $this->order->get_id() ); - - /** - * Try to reserve stock for the order. - * - * If creating a draft order on checkout entry, set the timeout to 10 mins. - * If POSTing to the checkout (attempting to pay), set the timeout to 60 mins (using the woocommerce_hold_stock_minutes option). - */ - try { - $reserve_stock = new ReserveStock(); - $duration = $request->get_method() === 'POST' ? (int) get_option( 'woocommerce_hold_stock_minutes', 60 ) : 10; - $reserve_stock->reserve_stock_for_order( $this->order, $duration ); - } catch ( ReserveStockException $e ) { - throw new RouteException( - $e->getErrorCode(), - $e->getMessage(), - $e->getCode() - ); - } } /** @@ -523,7 +593,8 @@ class Checkout extends AbstractCartRoute { $customer_id = $this->create_customer_account( $request['billing_address']['email'], $request['billing_address']['first_name'], - $request['billing_address']['last_name'] + $request['billing_address']['last_name'], + $request['customer_password'] ); // Associate customer with the order. This is done before login to ensure the order is associated with @@ -546,13 +617,23 @@ class Checkout extends AbstractCartRoute { case 'registration-error-invalid-email': throw new RouteException( 'registration-error-invalid-email', - __( 'Please provide a valid email address.', 'woocommerce' ), + esc_html__( 'Please provide a valid email address.', 'woocommerce' ), 400 ); case 'registration-error-email-exists': throw new RouteException( 'registration-error-email-exists', - __( 'An account is already registered with your email address. Please log in before proceeding.', 'woocommerce' ), + sprintf( + // Translators: %s Email address. + esc_html__( 'An account is already registered with %s. Please log in or use a different email address.', 'woocommerce' ), + esc_html( $request['billing_address']['email'] ) + ), + 400 + ); + case 'registration-error-empty-password': + throw new RouteException( + 'registration-error-empty-password', + esc_html__( 'Please create a password for your account.', 'woocommerce' ), 400 ); } @@ -607,10 +688,11 @@ class Checkout extends AbstractCartRoute { * @param string $user_email The email address to use for the new account. * @param string $first_name The first name to use for the new account. * @param string $last_name The last name to use for the new account. + * @param string $password The password to use for the new account. If empty, a password will be generated. * * @return int User id if successful */ - private function create_customer_account( $user_email, $first_name, $last_name ) { + private function create_customer_account( $user_email, $first_name, $last_name, $password = '' ) { if ( empty( $user_email ) || ! is_email( $user_email ) ) { throw new \Exception( 'registration-error-invalid-email' ); } @@ -619,11 +701,20 @@ class Checkout extends AbstractCartRoute { throw new \Exception( 'registration-error-email-exists' ); } - $username = wc_create_new_customer_username( $user_email ); + // Handle password creation if not provided. + if ( empty( $password ) ) { + $password = wp_generate_password(); + $password_generated = true; + } else { + $password_generated = false; + } - // Handle password creation. - $password = wp_generate_password(); - $password_generated = true; + // This ensures `wp_generate_password` returned something (it is filterable and could be empty string). + if ( empty( $password ) ) { + throw new \Exception( 'registration-error-empty-password' ); + } + + $username = wc_create_new_customer_username( $user_email ); // Use WP_Error to handle registration errors. $errors = new \WP_Error(); diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Patterns.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Patterns.php index 41d16a07ed5..96a0e37ce34 100644 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/Patterns.php +++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Patterns.php @@ -2,6 +2,7 @@ namespace Automattic\WooCommerce\StoreApi\Routes\V1; +use Automattic\WooCommerce\Blocks\BlockPatterns; use Automattic\WooCommerce\Blocks\Package; use Automattic\WooCommerce\Blocks\Patterns\PTKClient; use Automattic\WooCommerce\Blocks\Patterns\PTKPatternsStore; @@ -114,7 +115,11 @@ class Patterns extends AbstractRoute { protected function get_route_post_response( WP_REST_Request $request ) { $ptk_patterns_store = Package::container()->get( PTKPatternsStore::class ); - $ptk_patterns_store->fetch_patterns(); + $patterns = $ptk_patterns_store->fetch_patterns(); + + $block_patterns = Package::container()->get( BlockPatterns::class ); + + $block_patterns->register_ptk_patterns( $patterns ); return rest_ensure_response( array( diff --git a/plugins/woocommerce/src/StoreApi/RoutesController.php b/plugins/woocommerce/src/StoreApi/RoutesController.php index 64433ce6fe7..502cf1e4ddd 100644 --- a/plugins/woocommerce/src/StoreApi/RoutesController.php +++ b/plugins/woocommerce/src/StoreApi/RoutesController.php @@ -36,7 +36,7 @@ class RoutesController { public function __construct( SchemaController $schema_controller ) { $this->schema_controller = $schema_controller; $this->routes = [ - 'v1' => [ + 'v1' => [ Routes\V1\Batch::IDENTIFIER => Routes\V1\Batch::class, Routes\V1\Cart::IDENTIFIER => Routes\V1\Cart::class, Routes\V1\CartAddItem::IDENTIFIER => Routes\V1\CartAddItem::class, @@ -66,17 +66,6 @@ class RoutesController { Routes\V1\ProductsById::IDENTIFIER => Routes\V1\ProductsById::class, Routes\V1\ProductsBySlug::IDENTIFIER => Routes\V1\ProductsBySlug::class, ], - // @todo Migrate internal AI routes to WooCommerce Core codebase. - 'private' => [ - Routes\V1\AI\StoreTitle::IDENTIFIER => Routes\V1\AI\StoreTitle::class, - Routes\V1\AI\Images::IDENTIFIER => Routes\V1\AI\Images::class, - Routes\V1\AI\Patterns::IDENTIFIER => Routes\V1\AI\Patterns::class, - Routes\V1\AI\Product::IDENTIFIER => Routes\V1\AI\Product::class, - Routes\V1\AI\Products::IDENTIFIER => Routes\V1\AI\Products::class, - Routes\V1\AI\BusinessDescription::IDENTIFIER => Routes\V1\AI\BusinessDescription::class, - Routes\V1\AI\StoreInfo::IDENTIFIER => Routes\V1\AI\StoreInfo::class, - Routes\V1\Patterns::IDENTIFIER => Routes\V1\Patterns::class, - ], ]; } diff --git a/plugins/woocommerce/src/StoreApi/SchemaController.php b/plugins/woocommerce/src/StoreApi/SchemaController.php index 090d5c9d32d..0454c4d416f 100644 --- a/plugins/woocommerce/src/StoreApi/SchemaController.php +++ b/plugins/woocommerce/src/StoreApi/SchemaController.php @@ -54,14 +54,6 @@ class SchemaController { Schemas\V1\ProductCategorySchema::IDENTIFIER => Schemas\V1\ProductCategorySchema::class, Schemas\V1\ProductCollectionDataSchema::IDENTIFIER => Schemas\V1\ProductCollectionDataSchema::class, Schemas\V1\ProductReviewSchema::IDENTIFIER => Schemas\V1\ProductReviewSchema::class, - Schemas\V1\AI\StoreTitleSchema::IDENTIFIER => Schemas\V1\AI\StoreTitleSchema::class, - Schemas\V1\AI\ImagesSchema::IDENTIFIER => Schemas\V1\AI\ImagesSchema::class, - Schemas\V1\AI\PatternsSchema::IDENTIFIER => Schemas\V1\AI\PatternsSchema::class, - Schemas\V1\AI\ProductSchema::IDENTIFIER => Schemas\V1\AI\ProductSchema::class, - Schemas\V1\AI\ProductsSchema::IDENTIFIER => Schemas\V1\AI\ProductsSchema::class, - Schemas\V1\AI\BusinessDescriptionSchema::IDENTIFIER => Schemas\V1\AI\BusinessDescriptionSchema::class, - Schemas\V1\AI\StoreInfoSchema::IDENTIFIER => Schemas\V1\AI\StoreInfoSchema::class, - Schemas\V1\PatternsSchema::IDENTIFIER => Schemas\V1\PatternsSchema::class, ], ]; } diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/BusinessDescriptionSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/BusinessDescriptionSchema.php deleted file mode 100644 index b628fe9c33a..00000000000 --- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/BusinessDescriptionSchema.php +++ /dev/null @@ -1,47 +0,0 @@ - true, - ]; - } -} diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/ImagesSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/ImagesSchema.php deleted file mode 100644 index 317e4c028f6..00000000000 --- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/ImagesSchema.php +++ /dev/null @@ -1,45 +0,0 @@ - true, - ]; - } -} diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/ProductSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/ProductSchema.php deleted file mode 100644 index d2c4aa4ccd4..00000000000 --- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/ProductSchema.php +++ /dev/null @@ -1,47 +0,0 @@ - true, - ]; - } -} diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/ProductsSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/ProductsSchema.php deleted file mode 100644 index 114df7807af..00000000000 --- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/ProductsSchema.php +++ /dev/null @@ -1,48 +0,0 @@ - $item['ai_content_generated'], - 'product_content' => $item['product_content'], - ]; - } -} diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/StoreInfoSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/StoreInfoSchema.php deleted file mode 100644 index 251e702d9a9..00000000000 --- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AI/StoreInfoSchema.php +++ /dev/null @@ -1,34 +0,0 @@ - true, - ]; - } -} diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php index 3d77a9e2aaa..cdcf28e56f1 100644 --- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php +++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php @@ -164,7 +164,8 @@ abstract class AbstractAddressSchema extends AbstractSchema { $address = (array) $address; $validation_util = new ValidationUtils(); $schema = $this->get_properties(); - // omit all keys from address that are not in the schema. This should account for email. + + // Omit all keys from address that are not in the schema. This should account for email. $address = array_intersect_key( $address, $schema ); // The flow is Validate -> Sanitize -> Re-Validate @@ -172,6 +173,14 @@ abstract class AbstractAddressSchema extends AbstractSchema { // correct format, and finally the second validation step is to ensure the correctly-formatted values // match what we expect (postcode etc.). foreach ( $address as $key => $value ) { + + // Only run specific validation on properties that are defined in the schema and present in the address. + // This is for partial address pushes when only part of a customer address is sent. + // Full schema address validation still happens later, so empty, required values are disallowed. + if ( empty( $schema[ $key ] ) || empty( $address[ $key ] ) ) { + continue; + } + if ( is_wp_error( rest_validate_value_from_schema( $value, $schema[ $key ], $key ) ) ) { $errors->add( 'invalid_' . $key, diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CartExtensionsSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CartExtensionsSchema.php index c9f3dfc788f..1135501468c 100644 --- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CartExtensionsSchema.php +++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CartExtensionsSchema.php @@ -65,21 +65,27 @@ class CartExtensionsSchema extends AbstractSchema { } catch ( \Exception $e ) { throw new RouteException( 'woocommerce_rest_cart_extensions_error', - $e->getMessage(), + esc_html( $e->getMessage() ), 400 ); } - $controller = new CartController(); + // Run the callback. Exceptions are not caught here. + $callback( $request['data'] ); - if ( is_callable( $callback ) ) { - $callback( $request['data'] ); + try { // We recalculate the cart if we had something to run. - $controller->calculate_totals(); + $controller = new CartController(); + $cart = $controller->calculate_totals(); + $response = $this->cart_schema->get_item_response( $cart ); + + return rest_ensure_response( $response ); + } catch ( \Exception $e ) { + throw new RouteException( + 'woocommerce_rest_cart_extensions_error', + esc_html( $e->getMessage() ), + 400 + ); } - - $cart = $controller->get_cart_instance(); - - return rest_ensure_response( $this->cart_schema->get_item_response( $cart ) ); } } diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php index d61c84d5af5..95a1694b1f8 100644 --- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php +++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php @@ -319,6 +319,9 @@ class CheckoutSchema extends AbstractSchema { }, $field['options'] ); + if ( true !== $field['required'] ) { + $field_schema['enum'][] = ''; + } } if ( 'checkbox' === $field['type'] ) { diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/PatternsSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/PatternsSchema.php deleted file mode 100644 index 240925851f5..00000000000 --- a/plugins/woocommerce/src/StoreApi/Schemas/V1/PatternsSchema.php +++ /dev/null @@ -1,43 +0,0 @@ - true, - ]; - } -} diff --git a/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php index dedc60ae418..4e80f2695f8 100644 --- a/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php +++ b/plugins/woocommerce/src/StoreApi/Utilities/DraftOrderTrait.php @@ -60,8 +60,9 @@ trait DraftOrderTrait { return true; } - // Pending and failed orders can be retried if the cart hasn't changed. - if ( $order_object->needs_payment() && $order_object->has_cart_hash( wc()->cart->get_cart_hash() ) ) { + // Failed orders and those needing payment can be retried if the cart hasn't changed. + // Pending orders are excluded from this check since they may be awaiting an update from the payment processor. + if ( $order_object->needs_payment() && ! $order_object->has_status( 'pending' ) && $order_object->has_cart_hash( wc()->cart->get_cart_hash() ) ) { return true; } diff --git a/plugins/woocommerce/src/StoreApi/Utilities/JsonWebToken.php b/plugins/woocommerce/src/StoreApi/Utilities/JsonWebToken.php index b7848dcac5c..88a831f1de3 100644 --- a/plugins/woocommerce/src/StoreApi/Utilities/JsonWebToken.php +++ b/plugins/woocommerce/src/StoreApi/Utilities/JsonWebToken.php @@ -50,6 +50,10 @@ final class JsonWebToken { * @return bool */ public static function validate( string $token, string $secret ) { + if ( ! $token ) { + return false; + } + /** * Confirm the structure of a JSON Web Token, it has three parts separated * by dots and complies with Base64URL standards. diff --git a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php index 4ef0342b28d..1a2cbbec879 100644 --- a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php +++ b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php @@ -381,30 +381,16 @@ class OrderController { $address = $order->get_address( $address_type ); $current_locale = isset( $all_locales[ $address['country'] ] ) ? $all_locales[ $address['country'] ] : array(); + foreach ( $all_locales['default'] as $key => $value ) { + $default_value = empty( $current_locale[ $key ] ) ? [] : $current_locale[ $key ]; + $current_locale[ $key ] = wp_parse_args( $default_value, $value ); + } + $additional_fields = $this->additional_fields_controller->get_all_fields_from_object( $order, $address_type ); $address = array_merge( $address, $additional_fields ); - $fields = $this->additional_fields_controller->get_additional_fields(); - $address_fields_keys = $this->additional_fields_controller->get_address_fields_keys(); - $address_fields = array_filter( - $fields, - function ( $key ) use ( $address_fields_keys ) { - return in_array( $key, $address_fields_keys, true ); - }, - ARRAY_FILTER_USE_KEY - ); - - if ( $current_locale ) { - foreach ( $current_locale as $key => $field ) { - if ( isset( $address_fields[ $key ] ) ) { - $address_fields[ $key ]['label'] = isset( $field['label'] ) ? $field['label'] : $address_fields[ $key ]['label']; - $address_fields[ $key ]['required'] = isset( $field['required'] ) ? $field['required'] : $address_fields[ $key ]['required']; - } - } - } - - foreach ( $address_fields as $address_field_key => $address_field ) { + foreach ( $current_locale as $address_field_key => $address_field ) { if ( empty( $address[ $address_field_key ] ) && $address_field['required'] ) { /* translators: %s Field label. */ $errors->add( $address_type, sprintf( __( '%s is required', 'woocommerce' ), $address_field['label'] ), $address_field_key ); diff --git a/plugins/woocommerce/src/StoreApi/docs/cart-tokens.md b/plugins/woocommerce/src/StoreApi/docs/cart-tokens.md new file mode 100644 index 00000000000..0002e00f000 --- /dev/null +++ b/plugins/woocommerce/src/StoreApi/docs/cart-tokens.md @@ -0,0 +1,37 @@ +# Cart Tokens + +## Table of Contents + +- [Obtaining a Cart Token](#obtaining-a-cart-token) +- [How to use a Cart-Token](#how-to-use-a-cart-token) + +Cart tokens can be used instead of cookies based sessions for headless interaction with carts. When using a `Cart-Token` a [Nonce Token](nonce-tokens.md) is not required. + +## Obtaining a Cart Token + +Requests to `/cart` endpoints return a `Cart-Token` header alongside the response. This contains a token which can later be sent as a request header to the Store API Cart and Checkout endpoints to identify the cart. + +The quickest method of obtaining a Cart Token is to make a GET request `/wp-json/wc/store/v1/cart` and observe the response headers. You should see a `Cart-Token` header there. + +## How to use a Cart-Token + +To use a `Cart-Token`, include it as a header with your request. The response will contain the current cart state from the session associated with the `Cart-Token`. + +**Example:** + +```sh +curl --header "Cart-Token: 12345" --request GET https://example-store.com/wp-json/wc/store/v1/cart +``` + +The same method will allow you to checkout using a `Cart-Token` on the `/checkout` route. + + + +--- + +[We're hiring!](https://woocommerce.com/careers/) Come work with us! + +🐞 Found a mistake, or have a suggestion? [Leave feedback about this document here.](https://github.com/woocommerce/woocommerce-blocks/issues/new?assignees=&labels=type%3A+documentation&template=--doc-feedback.md&title=Feedback%20on%20./src/StoreApi/docs/cart-tokens.md) + + + diff --git a/plugins/woocommerce/src/StoreApi/docs/cart.md b/plugins/woocommerce/src/StoreApi/docs/cart.md index dfc78215069..1990cf7f6bf 100644 --- a/plugins/woocommerce/src/StoreApi/docs/cart.md +++ b/plugins/woocommerce/src/StoreApi/docs/cart.md @@ -16,7 +16,7 @@ The cart API returns the current state of the cart for the current session or logged in user. -All POST endpoints require [Nonce Tokens](nonce-tokens.md) and return the updated state of the full cart once complete. +All POST endpoints require a [Nonce Token](nonce-tokens.md) or a [Cart Token](cart-tokens.md) and return the updated state of the full cart once complete. ## Get Cart @@ -391,7 +391,7 @@ This allows the client to remain in sync with the cart data without additional r Add an item to the cart and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/add-item @@ -494,7 +494,7 @@ The JSON payload for adding multiple items to the cart would look like this: Remove an item from the cart and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/remove-item @@ -514,7 +514,7 @@ Returns the full [Cart Response](#cart-response) on success, or an [Error Respon Update an item in the cart and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/update-item @@ -535,7 +535,7 @@ Returns the full [Cart Response](#cart-response) on success, or an [Error Respon Apply a coupon to the cart and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/apply-coupon/ @@ -555,7 +555,7 @@ Returns the full [Cart Response](#cart-response) on success, or an [Error Respon Remove a coupon from the cart and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/remove-coupon/ @@ -575,7 +575,7 @@ Returns the full [Cart Response](#cart-response) on success, or an [Error Respon Update customer data and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/update-customer @@ -610,7 +610,7 @@ Returns the full [Cart Response](#cart-response) on success, or an [Error Respon Selects an available shipping rate for a package, then returns the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/select-shipping-rate diff --git a/plugins/woocommerce/src/StoreApi/docs/checkout-order.md b/plugins/woocommerce/src/StoreApi/docs/checkout-order.md index 168f679a6c0..53c2a4437af 100644 --- a/plugins/woocommerce/src/StoreApi/docs/checkout-order.md +++ b/plugins/woocommerce/src/StoreApi/docs/checkout-order.md @@ -7,15 +7,13 @@ The checkout order API facilitates the processing of existing orders and handling payments. -All checkout order endpoints require [Nonce Tokens](nonce-tokens.md). +All checkout order endpoints require a [Nonce Token](nonce-tokens.md) or a [Cart Token](cart-tokens.md) otherwise these endpoints will return an error. ## Process Order and Payment Accepts the final chosen payment method, and any additional payment data, then attempts payment and returns the result. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. - ```http POST /wc/store/v1/checkout/{ORDER_ID} ``` diff --git a/plugins/woocommerce/src/StoreApi/docs/checkout.md b/plugins/woocommerce/src/StoreApi/docs/checkout.md index ef09146e411..4dcae15739b 100644 --- a/plugins/woocommerce/src/StoreApi/docs/checkout.md +++ b/plugins/woocommerce/src/StoreApi/docs/checkout.md @@ -7,17 +7,12 @@ The checkout API facilitates the creation of orders (from the current cart) and handling payments for payment methods. -All checkout endpoints require [Nonce Tokens](nonce-tokens.md). - -- [Get Checkout Data](#get-checkout-data) -- [Process Order and Payment](#process-order-and-payment) +All checkout endpoints require either a [Nonce Token](nonce-tokens.md) or a [Cart Token](cart-tokens.md) otherwise these endpoints will return an error. ## Get Checkout Data Returns data required for the checkout. This includes a draft order (created from the current cart) and customer billing and shipping addresses. The payment information will be empty, as it's only persisted when the order gets updated via POST requests (right before payment processing). -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. - ```http GET /wc/store/v1/checkout ``` @@ -75,8 +70,6 @@ curl --header "Nonce: 12345" --request GET https://example-store.com/wp-json/wc/ Accepts the final customer addresses and chosen payment method, and any additional payment data, then attempts payment and returns the result. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. - ```http POST /wc/store/v1/checkout ``` @@ -88,6 +81,7 @@ POST /wc/store/v1/checkout | `customer_note` | string | No | Note added to the order by the customer during checkout. | | `payment_method` | string | Yes | The ID of the payment method being used to process the payment. | | `payment_data` | array | No | Data to pass through to the payment method when processing payment. | +| `customer_password`| string | No | Optionally define a password for new accounts. | ```sh curl --header "Nonce: 12345" --request POST https://example-store.com/wp-json/wc/store/v1/checkout?payment_method=paypal&payment_data[0][key]=test-key&payment_data[0][value]=test-value diff --git a/plugins/woocommerce/src/StoreApi/docs/nonce-tokens.md b/plugins/woocommerce/src/StoreApi/docs/nonce-tokens.md index 0558a999a13..e6ba7788c4c 100644 --- a/plugins/woocommerce/src/StoreApi/docs/nonce-tokens.md +++ b/plugins/woocommerce/src/StoreApi/docs/nonce-tokens.md @@ -11,7 +11,7 @@ Nonces are generated numbers used to verify origin and intent of requests for se ## Store API Endpoints that Require Nonces -POST requests to the `/cart` endpoints and all requests to the `/checkout` endpoints require a nonce to function. Failure to provide a valid nonce will return an error response. +POST requests to the `/cart` endpoints and all requests to the `/checkout` endpoints require a nonce to function. Failure to provide a valid nonce will return an error response, unless you're using [Cart Tokens](cart-tokens.md) instead. ## Sending Nonce Tokens with requests diff --git a/plugins/woocommerce/src/Utilities/LoggingUtil.php b/plugins/woocommerce/src/Utilities/LoggingUtil.php index 18ab4727e9b..9d49fe4ebe6 100644 --- a/plugins/woocommerce/src/Utilities/LoggingUtil.php +++ b/plugins/woocommerce/src/Utilities/LoggingUtil.php @@ -86,10 +86,12 @@ final class LoggingUtil { /** * Get the directory for storing log files. * + * @param bool $create_dir Optional. True to attempt to create the log directory if it doesn't exist. Default true. + * * @return string The full directory path, with trailing slash. */ - public static function get_log_directory(): string { - return Settings::get_log_directory(); + public static function get_log_directory( bool $create_dir = true ): string { + return Settings::get_log_directory( $create_dir ); } /** diff --git a/plugins/woocommerce/src/Utilities/OrderUtil.php b/plugins/woocommerce/src/Utilities/OrderUtil.php index 8e94cbb8a9c..07525be6adb 100644 --- a/plugins/woocommerce/src/Utilities/OrderUtil.php +++ b/plugins/woocommerce/src/Utilities/OrderUtil.php @@ -228,4 +228,19 @@ final class OrderUtil { return $count_per_status; } + /** + * Removes the 'wc-' prefix from status. + * + * @param string $status The status to remove the prefix from. + * + * @return string The status without the prefix. + * @since 9.2.0 + */ + public static function remove_status_prefix( string $status ): string { + if ( strpos( $status, 'wc-' ) === 0 ) { + $status = substr( $status, 3 ); + } + + return $status; + } } diff --git a/plugins/woocommerce/src/Utilities/PluginUtil.php b/plugins/woocommerce/src/Utilities/PluginUtil.php index 6bd063e86e3..d206e0e2e32 100644 --- a/plugins/woocommerce/src/Utilities/PluginUtil.php +++ b/plugins/woocommerce/src/Utilities/PluginUtil.php @@ -67,6 +67,37 @@ class PluginUtil { require_once ABSPATH . WPINC . '/plugin.php'; } + /** + * Wrapper for WP's private `wp_get_active_and_valid_plugins` and `wp_get_active_network_plugins` functions. + * + * This combines the results of the two functions to get a list of all plugins that are active within a site. + * It's more useful than just retrieving the option values because it also validates that the plugin files exist. + * This wrapper is also a hedge against backward-incompatible changes since both of the WP methods are marked as + * being "@access private", so if need be we can update our methods here to preserve functionality. + * + * Note that the doc block for `wp_get_active_and_valid_plugins` says it returns "Array of paths to plugin files + * relative to the plugins directory", but it actually returns absolute paths. + * + * @return string[] Array of plugin basenames (paths relative to the plugin directory). + */ + public function get_all_active_valid_plugins() { + $local = wp_get_active_and_valid_plugins(); + + if ( is_multisite() ) { + require_once ABSPATH . WPINC . '/ms-load.php'; + $network = wp_get_active_network_plugins(); + } else { + $network = array(); + } + + $all = array_merge( $local, $network ); + $all = array_unique( $all ); + $all = array_map( 'plugin_basename', $all ); + sort( $all ); + + return $all; + } + /** * Get a list with the names of the WordPress plugins that are WooCommerce aware * (they have a "WC tested up to" header). diff --git a/plugins/woocommerce/templates/cart/mini-cart.php b/plugins/woocommerce/templates/cart/mini-cart.php index 34b3b021d3c..ecf51eb96eb 100644 --- a/plugins/woocommerce/templates/cart/mini-cart.php +++ b/plugins/woocommerce/templates/cart/mini-cart.php @@ -14,7 +14,7 @@ * * @see https://woocommerce.com/document/template-structure/ * @package WooCommerce\Templates - * @version 7.9.0 + * @version 9.2.0 */ defined( 'ABSPATH' ) || exit; @@ -47,13 +47,15 @@ do_action( 'woocommerce_before_mini_cart' ); ?> echo apply_filters( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped 'woocommerce_cart_item_remove_link', sprintf( - '×', + '×', esc_url( wc_get_cart_remove_url( $cart_item_key ) ), /* translators: %s is the product name */ esc_attr( sprintf( __( 'Remove %s from cart', 'woocommerce' ), wp_strip_all_tags( $product_name ) ) ), esc_attr( $product_id ), esc_attr( $cart_item_key ), - esc_attr( $_product->get_sku() ) + esc_attr( $_product->get_sku() ), + /* translators: %s is the product name */ + esc_attr( sprintf( __( '“%s” has been removed from your cart', 'woocommerce' ), wp_strip_all_tags( $product_name ) ) ) ), $cart_item_key ); diff --git a/plugins/woocommerce/templates/emails/customer-reset-password.php b/plugins/woocommerce/templates/emails/customer-reset-password.php index d2b62cfc10f..8549bcdfa79 100644 --- a/plugins/woocommerce/templates/emails/customer-reset-password.php +++ b/plugins/woocommerce/templates/emails/customer-reset-password.php @@ -12,7 +12,7 @@ * * @see https://woocommerce.com/document/template-structure/ * @package WooCommerce\Templates\Emails - * @version 4.0.0 + * @version 9.3.0 */ if ( ! defined( 'ABSPATH' ) ) { @@ -31,7 +31,7 @@ if ( ! defined( 'ABSPATH' ) ) {

- +

diff --git a/plugins/woocommerce/templates/emails/email-styles.php b/plugins/woocommerce/templates/emails/email-styles.php index 0fc699e887c..b51eb63563b 100644 --- a/plugins/woocommerce/templates/emails/email-styles.php +++ b/plugins/woocommerce/templates/emails/email-styles.php @@ -12,7 +12,7 @@ * * @see https://woocommerce.com/document/template-structure/ * @package WooCommerce\Templates\Emails - * @version 8.6.0 + * @version 9.3.0 */ if ( ! defined( 'ABSPATH' ) ) { @@ -20,11 +20,12 @@ if ( ! defined( 'ABSPATH' ) ) { } // Load colors. -$bg = get_option( 'woocommerce_email_background_color' ); -$body = get_option( 'woocommerce_email_body_background_color' ); -$base = get_option( 'woocommerce_email_base_color' ); -$base_text = wc_light_or_dark( $base, '#202020', '#ffffff' ); -$text = get_option( 'woocommerce_email_text_color' ); +$bg = get_option( 'woocommerce_email_background_color' ); +$body = get_option( 'woocommerce_email_body_background_color' ); +$base = get_option( 'woocommerce_email_base_color' ); +$base_text = wc_light_or_dark( $base, '#202020', '#ffffff' ); +$text = get_option( 'woocommerce_email_text_color' ); +$footer_text = get_option( 'woocommerce_email_footer_text_color' ); // Pick a contrasting color for links. $link_color = wc_hex_is_light( $base ) ? $base : $base_text; @@ -97,7 +98,7 @@ body { #template_footer #credit { border: 0; - color: ; + color: ; font-family: "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif; font-size: 12px; line-height: 150%; @@ -190,6 +191,11 @@ body { display: block; } +#template_footer #credit, +#template_footer #credit a { + color: ; +} + h1 { color: ; font-family: "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif; diff --git a/plugins/woocommerce/templates/emails/plain/customer-reset-password.php b/plugins/woocommerce/templates/emails/plain/customer-reset-password.php index 4f824ee7f57..08db2454566 100644 --- a/plugins/woocommerce/templates/emails/plain/customer-reset-password.php +++ b/plugins/woocommerce/templates/emails/plain/customer-reset-password.php @@ -12,7 +12,7 @@ * * @see https://woocommerce.com/document/template-structure/ * @package WooCommerce\Templates\Emails\Plain - * @version 3.7.0 + * @version 9.3.0 */ defined( 'ABSPATH' ) || exit; @@ -28,7 +28,7 @@ echo sprintf( esc_html__( 'Someone has requested a new password for the followin /* translators: %s: Customer username */ echo sprintf( esc_html__( 'Username: %s', 'woocommerce' ), esc_html( $user_login ) ) . "\n\n"; echo esc_html__( 'If you didn\'t make this request, just ignore this email. If you\'d like to proceed:', 'woocommerce' ) . "\n\n"; -echo esc_url( add_query_arg( array( 'key' => $reset_key, 'id' => $user_id ), wc_get_endpoint_url( 'lost-password', '', wc_get_page_permalink( 'myaccount' ) ) ) ) . "\n\n"; // phpcs:ignore +echo esc_url( add_query_arg( array( 'key' => $reset_key, 'id' => $user_id, 'login' => rawurlencode( $user_login ) ), wc_get_endpoint_url( 'lost-password', '', wc_get_page_permalink( 'myaccount' ) ) ) ) . "\n\n"; // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound echo "\n\n----------------------------------------\n\n"; diff --git a/plugins/woocommerce/templates/global/quantity-input.php b/plugins/woocommerce/templates/global/quantity-input.php index 61dbe181f10..2ff50286e4f 100644 --- a/plugins/woocommerce/templates/global/quantity-input.php +++ b/plugins/woocommerce/templates/global/quantity-input.php @@ -12,7 +12,7 @@ * * @see https://woocommerce.com/document/template-structure/ * @package WooCommerce\Templates - * @version 7.8.0 + * @version 9.4.0 * * @var bool $readonly If the input should be set to readonly mode. * @var string $type The input type attribute. @@ -42,7 +42,9 @@ $label = ! empty( $args['product_name'] ) ? sprintf( esc_html__( '%s quantity', name="" value="" aria-label="" - size="4" + + size="4" + min="" max="" diff --git a/plugins/woocommerce/templates/loop/add-to-cart.php b/plugins/woocommerce/templates/loop/add-to-cart.php index ad3f086e2a9..934ba5db90d 100644 --- a/plugins/woocommerce/templates/loop/add-to-cart.php +++ b/plugins/woocommerce/templates/loop/add-to-cart.php @@ -12,7 +12,7 @@ * * @see https://woocommerce.com/document/template-structure/ * @package WooCommerce\Templates - * @version 9.0.0 + * @version 9.2.0 */ if ( ! defined( 'ABSPATH' ) ) { @@ -21,12 +21,14 @@ if ( ! defined( 'ABSPATH' ) ) { global $product; +$aria_describedby = isset( $args['aria-describedby_text'] ) ? sprintf( 'aria-describedby="woocommerce_loop_add_to_cart_link_describedby_%s"', esc_attr( $product->get_id() ) ) : ''; + echo apply_filters( 'woocommerce_loop_add_to_cart_link', // WPCS: XSS ok. sprintf( - '%s', + '%s', esc_url( $product->add_to_cart_url() ), - esc_attr( $product->get_id() ), + $aria_describedby, esc_attr( isset( $args['quantity'] ) ? $args['quantity'] : 1 ), esc_attr( isset( $args['class'] ) ? $args['class'] : 'button' ), isset( $args['attributes'] ) ? wc_implode_html_attributes( $args['attributes'] ) : '', @@ -36,6 +38,8 @@ echo apply_filters( $args ); ?> - - - + + + + + diff --git a/plugins/woocommerce/templates/loop/pagination.php b/plugins/woocommerce/templates/loop/pagination.php index f9abc74d655..ab909777c1f 100644 --- a/plugins/woocommerce/templates/loop/pagination.php +++ b/plugins/woocommerce/templates/loop/pagination.php @@ -12,7 +12,7 @@ * * @see https://woocommerce.com/document/template-structure/ * @package WooCommerce\Templates - * @version 3.3.1 + * @version 9.3.0 */ if ( ! defined( 'ABSPATH' ) ) { @@ -28,7 +28,7 @@ if ( $total <= 1 ) { return; } ?> -