From 9b41815229b09ec0fa452bf632afc0b89bcfcfaa Mon Sep 17 00:00:00 2001 From: Barry Hughes <3594411+barryhughes@users.noreply.github.com> Date: Fri, 13 Sep 2024 11:39:37 -0700 Subject: [PATCH 01/36] Produce CSV Import enhancements (cherry-pick PR#51344) (#51348) * Move AJAX callback to produt importer controller * Add better validation for file paths in CSV product import * Add utility function to create a no-index directory * Add CSV upload helper * Simplify product CSV import logic using helper * Update tests * Add changelog * Changelog. * Remove changelog file; entry added directly to root changelog.txt. --------- Co-authored-by: Jorge Torres --- changelog.txt | 1 + .../admin/class-wc-admin-importers.php | 106 +------ ...ass-wc-product-csv-importer-controller.php | 269 +++++++++++++----- plugins/woocommerce/src/Container.php | 2 + .../Admin/ImportExport/CSVUploadHelper.php | 175 ++++++++++++ .../ImportExportServiceProvider.php | 35 +++ .../src/Internal/Utilities/FilesystemUtil.php | 30 ++ .../tests/merchant/product-import-csv.spec.js | 3 +- 8 files changed, 448 insertions(+), 173 deletions(-) create mode 100644 plugins/woocommerce/src/Internal/Admin/ImportExport/CSVUploadHelper.php create mode 100644 plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ImportExportServiceProvider.php diff --git a/changelog.txt b/changelog.txt index 19f15145abc..2ba165e2bdc 100644 --- a/changelog.txt +++ b/changelog.txt @@ -145,6 +145,7 @@ * Update - Update core profiler continue button container on extension screen [#50582](https://github.com/woocommerce/woocommerce/pull/50582) * Update - Update Store Alert actions to have unique keys. [#50424](https://github.com/woocommerce/woocommerce/pull/50424) * Update - Update WooCommercePayments task is_supported to use default suggestions [#50585](https://github.com/woocommerce/woocommerce/pull/50585) +* Update - Enhance CSV path and upload handling in product import [#51344](https://github.com/woocommerce/woocommerce/pull/51344) * Dev - Execute test env setup on host instead of wp-env container [#51021](https://github.com/woocommerce/woocommerce/pull/51021) * Dev - Added code docs with examples to the Analytics classes [#49425](https://github.com/woocommerce/woocommerce/pull/49425) * Dev - Add lost password e2e tests [#50611](https://github.com/woocommerce/woocommerce/pull/50611) diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-importers.php b/plugins/woocommerce/includes/admin/class-wc-admin-importers.php index 6dfec075b89..3c16a0b07d9 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-importers.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-importers.php @@ -215,114 +215,12 @@ class WC_Admin_Importers { * Ajax callback for importing one batch of products from a CSV. */ public function do_ajax_product_import() { - global $wpdb; - - check_ajax_referer( 'wc-product-import', 'security' ); - - if ( ! $this->import_allowed() || ! isset( $_POST['file'] ) ) { // PHPCS: input var ok. + if ( ! $this->import_allowed() ) { wp_send_json_error( array( 'message' => __( 'Insufficient privileges to import products.', 'woocommerce' ) ) ); } include_once WC_ABSPATH . 'includes/admin/importers/class-wc-product-csv-importer-controller.php'; - include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php'; - - $file = wc_clean( wp_unslash( $_POST['file'] ) ); // PHPCS: input var ok. - $params = array( - 'delimiter' => ! empty( $_POST['delimiter'] ) ? wc_clean( wp_unslash( $_POST['delimiter'] ) ) : ',', // PHPCS: input var ok. - 'start_pos' => isset( $_POST['position'] ) ? absint( $_POST['position'] ) : 0, // PHPCS: input var ok. - 'mapping' => isset( $_POST['mapping'] ) ? (array) wc_clean( wp_unslash( $_POST['mapping'] ) ) : array(), // PHPCS: input var ok. - 'update_existing' => isset( $_POST['update_existing'] ) ? (bool) $_POST['update_existing'] : false, // PHPCS: input var ok. - 'character_encoding' => isset( $_POST['character_encoding'] ) ? wc_clean( wp_unslash( $_POST['character_encoding'] ) ) : '', - - /** - * Batch size for the product import process. - * - * @param int $size Batch size. - * - * @since 3.1.0 - */ - 'lines' => apply_filters( 'woocommerce_product_import_batch_size', 30 ), - 'parse' => true, - ); - - // Log failures. - if ( 0 !== $params['start_pos'] ) { - $error_log = array_filter( (array) get_user_option( 'product_import_error_log' ) ); - } else { - $error_log = array(); - } - - $importer = WC_Product_CSV_Importer_Controller::get_importer( $file, $params ); - $results = $importer->import(); - $percent_complete = $importer->get_percent_complete(); - $error_log = array_merge( $error_log, $results['failed'], $results['skipped'] ); - - update_user_option( get_current_user_id(), 'product_import_error_log', $error_log ); - - if ( 100 === $percent_complete ) { - // @codingStandardsIgnoreStart. - $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_original_id' ) ); - $wpdb->delete( $wpdb->posts, array( - 'post_type' => 'product', - 'post_status' => 'importing', - ) ); - $wpdb->delete( $wpdb->posts, array( - 'post_type' => 'product_variation', - 'post_status' => 'importing', - ) ); - // @codingStandardsIgnoreEnd. - - // Clean up orphaned data. - $wpdb->query( - " - DELETE {$wpdb->posts}.* FROM {$wpdb->posts} - LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->posts}.post_parent - WHERE wp.ID IS NULL AND {$wpdb->posts}.post_type = 'product_variation' - " - ); - $wpdb->query( - " - DELETE {$wpdb->postmeta}.* FROM {$wpdb->postmeta} - LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->postmeta}.post_id - WHERE wp.ID IS NULL - " - ); - // @codingStandardsIgnoreStart. - $wpdb->query( " - DELETE tr.* FROM {$wpdb->term_relationships} tr - LEFT JOIN {$wpdb->posts} wp ON wp.ID = tr.object_id - LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id - WHERE wp.ID IS NULL - AND tt.taxonomy IN ( '" . implode( "','", array_map( 'esc_sql', get_object_taxonomies( 'product' ) ) ) . "' ) - " ); - // @codingStandardsIgnoreEnd. - - // Send success. - wp_send_json_success( - array( - 'position' => 'done', - 'percentage' => 100, - 'url' => add_query_arg( array( '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ) ), admin_url( 'edit.php?post_type=product&page=product_importer&step=done' ) ), - 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, - 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, - 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, - 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, - 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, - ) - ); - } else { - wp_send_json_success( - array( - 'position' => $importer->get_file_position(), - 'percentage' => $percent_complete, - 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, - 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, - 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, - 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, - 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, - ) - ); - } + WC_Product_CSV_Importer_Controller::dispatch_ajax(); } /** diff --git a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php index e5fac1cecb9..cadf53a7bc5 100644 --- a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php +++ b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php @@ -103,6 +103,56 @@ class WC_Product_CSV_Importer_Controller { return wc_is_file_valid_csv( $file, $check_path ); } + /** + * Runs before controller actions to check that the file used during the import is valid. + * + * @since 9.3.0 + * + * @param string $path Path to test. + * + * @throws \Exception When file validation fails. + */ + protected static function check_file_path( string $path ): void { + $is_valid_file = false; + + if ( ! empty( $path ) ) { + $path = realpath( $path ); + $is_valid_file = false !== $path; + } + + // File must be readable. + $is_valid_file = $is_valid_file && is_readable( $path ); + + // Check that file is within an allowed location. + if ( $is_valid_file ) { + $in_valid_location = false; + $valid_locations = array(); + $valid_locations[] = ABSPATH; + + $upload_dir = wp_get_upload_dir(); + if ( false === $upload_dir['error'] ) { + $valid_locations[] = $upload_dir['basedir']; + } + + foreach ( $valid_locations as $valid_location ) { + if ( 0 === stripos( $path, trailingslashit( realpath( $valid_location ) ) ) ) { + $in_valid_location = true; + break; + } + } + + $is_valid_file = $in_valid_location; + } + + if ( ! $is_valid_file ) { + throw new \Exception( esc_html__( 'File path provided for import is invalid.', 'woocommerce' ) ); + } + + if ( ! self::is_file_valid_csv( $path ) ) { + throw new \Exception( esc_html__( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); + } + } + /** * Get all the valid filetypes for a CSV file. * @@ -263,17 +313,151 @@ class WC_Product_CSV_Importer_Controller { * Dispatch current step and show correct view. */ public function dispatch() { - // phpcs:ignore WordPress.Security.NonceVerification.Missing - if ( ! empty( $_POST['save_step'] ) && ! empty( $this->steps[ $this->step ]['handler'] ) ) { - call_user_func( $this->steps[ $this->step ]['handler'], $this ); + $output = ''; + + try { + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( ! empty( $_POST['save_step'] ) && ! empty( $this->steps[ $this->step ]['handler'] ) ) { + if ( is_callable( $this->steps[ $this->step ]['handler'] ) ) { + call_user_func( $this->steps[ $this->step ]['handler'], $this ); + } + } + + ob_start(); + + if ( is_callable( $this->steps[ $this->step ]['view'] ) ) { + call_user_func( $this->steps[ $this->step ]['view'], $this ); + } + + $output = ob_get_clean(); + } catch ( \Exception $e ) { + $this->add_error( $e->getMessage() ); } + $this->output_header(); $this->output_steps(); $this->output_errors(); - call_user_func( $this->steps[ $this->step ]['view'], $this ); + echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- output is HTML we've generated ourselves. $this->output_footer(); } + /** + * Processes AJAX requests related to a product CSV import. + * + * @since 9.3.0 + */ + public static function dispatch_ajax() { + global $wpdb; + + check_ajax_referer( 'wc-product-import', 'security' ); + + try { + $file = wc_clean( wp_unslash( $_POST['file'] ?? '' ) ); // PHPCS: input var ok. + self::check_file_path( $file ); + + $params = array( + 'delimiter' => ! empty( $_POST['delimiter'] ) ? wc_clean( wp_unslash( $_POST['delimiter'] ) ) : ',', // PHPCS: input var ok. + 'start_pos' => isset( $_POST['position'] ) ? absint( $_POST['position'] ) : 0, // PHPCS: input var ok. + 'mapping' => isset( $_POST['mapping'] ) ? (array) wc_clean( wp_unslash( $_POST['mapping'] ) ) : array(), // PHPCS: input var ok. + 'update_existing' => isset( $_POST['update_existing'] ) ? (bool) $_POST['update_existing'] : false, // PHPCS: input var ok. + 'character_encoding' => isset( $_POST['character_encoding'] ) ? wc_clean( wp_unslash( $_POST['character_encoding'] ) ) : '', + + /** + * Batch size for the product import process. + * + * @param int $size Batch size. + * + * @since 3.1.0 + */ + 'lines' => apply_filters( 'woocommerce_product_import_batch_size', 1 ), + 'parse' => true, + ); + + // Log failures. + if ( 0 !== $params['start_pos'] ) { + $error_log = array_filter( (array) get_user_option( 'product_import_error_log' ) ); + } else { + $error_log = array(); + } + + include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php'; + + $importer = self::get_importer( $file, $params ); + $results = $importer->import(); + $percent_complete = $importer->get_percent_complete(); + $error_log = array_merge( $error_log, $results['failed'], $results['skipped'] ); + + update_user_option( get_current_user_id(), 'product_import_error_log', $error_log ); + + if ( 100 === $percent_complete ) { + // @codingStandardsIgnoreStart. + $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_original_id' ) ); + $wpdb->delete( $wpdb->posts, array( + 'post_type' => 'product', + 'post_status' => 'importing', + ) ); + $wpdb->delete( $wpdb->posts, array( + 'post_type' => 'product_variation', + 'post_status' => 'importing', + ) ); + // @codingStandardsIgnoreEnd. + + // Clean up orphaned data. + $wpdb->query( + " + DELETE {$wpdb->posts}.* FROM {$wpdb->posts} + LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->posts}.post_parent + WHERE wp.ID IS NULL AND {$wpdb->posts}.post_type = 'product_variation' + " + ); + $wpdb->query( + " + DELETE {$wpdb->postmeta}.* FROM {$wpdb->postmeta} + LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->postmeta}.post_id + WHERE wp.ID IS NULL + " + ); + // @codingStandardsIgnoreStart. + $wpdb->query( " + DELETE tr.* FROM {$wpdb->term_relationships} tr + LEFT JOIN {$wpdb->posts} wp ON wp.ID = tr.object_id + LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id + WHERE wp.ID IS NULL + AND tt.taxonomy IN ( '" . implode( "','", array_map( 'esc_sql', get_object_taxonomies( 'product' ) ) ) . "' ) + " ); + // @codingStandardsIgnoreEnd. + + // Send success. + wp_send_json_success( + array( + 'position' => 'done', + 'percentage' => 100, + 'url' => add_query_arg( array( '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ) ), admin_url( 'edit.php?post_type=product&page=product_importer&step=done' ) ), + 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, + 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, + 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, + 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, + 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, + ) + ); + } else { + wp_send_json_success( + array( + 'position' => $importer->get_file_position(), + 'percentage' => $percent_complete, + 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, + 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, + 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, + 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, + 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, + ) + ); + } + } catch ( \Exception $e ) { + wp_send_json_error( array( 'message' => $e->getMessage() ) ); + } + } + /** * Output information about the uploading process. */ @@ -314,60 +498,20 @@ class WC_Product_CSV_Importer_Controller { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce already verified in WC_Product_CSV_Importer_Controller::upload_form_handler() $file_url = isset( $_POST['file_url'] ) ? wc_clean( wp_unslash( $_POST['file_url'] ) ) : ''; - if ( empty( $file_url ) ) { - if ( ! isset( $_FILES['import'] ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_file_empty', __( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your php.ini or by post_max_size being defined as smaller than upload_max_filesize in php.ini.', 'woocommerce' ) ); + try { + if ( ! empty( $file_url ) ) { + $path = ABSPATH . $file_url; + self::check_file_path( $path ); + } else { + $csv_import_util = wc_get_container()->get( Automattic\WooCommerce\Internal\Admin\ImportExport\CSVUploadHelper::class ); + $upload = $csv_import_util->handle_csv_upload( 'product', 'import', self::get_valid_csv_filetypes() ); + $path = $upload['file']; } - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated - if ( ! self::is_file_valid_csv( wc_clean( wp_unslash( $_FILES['import']['name'] ) ), false ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_file_invalid', __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); - } - - $overrides = array( - 'test_form' => false, - 'mimes' => self::get_valid_csv_filetypes(), - ); - $import = $_FILES['import']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash - $upload = wp_handle_upload( $import, $overrides ); - - if ( isset( $upload['error'] ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_error', $upload['error'] ); - } - - // Construct the object array. - $object = array( - 'post_title' => basename( $upload['file'] ), - 'post_content' => $upload['url'], - 'post_mime_type' => $upload['type'], - 'guid' => $upload['url'], - 'context' => 'import', - 'post_status' => 'private', - ); - - // Save the data. - $id = wp_insert_attachment( $object, $upload['file'] ); - - /* - * Schedule a cleanup for one day from now in case of failed - * import or missing wp_import_cleanup() call. - */ - wp_schedule_single_event( time() + DAY_IN_SECONDS, 'importer_scheduled_cleanup', array( $id ) ); - - return $upload['file']; - } elseif ( - ( 0 === stripos( realpath( ABSPATH . $file_url ), ABSPATH ) ) && - file_exists( ABSPATH . $file_url ) - ) { - if ( ! self::is_file_valid_csv( ABSPATH . $file_url ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_file_invalid', __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); - } - - return ABSPATH . $file_url; + return $path; + } catch ( \Exception $e ) { + return new \WP_Error( 'woocommerce_product_csv_importer_upload_invalid_file', $e->getMessage() ); } - // phpcs:enable - - return new WP_Error( 'woocommerce_product_csv_importer_upload_invalid_file', __( 'Please upload or provide the link to a valid CSV file.', 'woocommerce' ) ); } /** @@ -375,6 +519,8 @@ class WC_Product_CSV_Importer_Controller { */ protected function mapping_form() { check_admin_referer( 'woocommerce-csv-importer' ); + self::check_file_path( $this->file ); + $args = array( 'lines' => 1, 'delimiter' => $this->delimiter, @@ -412,18 +558,7 @@ class WC_Product_CSV_Importer_Controller { // Displaying this page triggers Ajax action to run the import with a valid nonce, // therefore this page needs to be nonce protected as well. check_admin_referer( 'woocommerce-csv-importer' ); - - if ( ! self::is_file_valid_csv( $this->file ) ) { - $this->add_error( __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); - $this->output_errors(); - return; - } - - if ( ! is_file( $this->file ) ) { - $this->add_error( __( 'The file does not exist, please try again.', 'woocommerce' ) ); - $this->output_errors(); - return; - } + self::check_file_path( $this->file ); if ( ! empty( $_POST['map_from'] ) && ! empty( $_POST['map_to'] ) ) { $mapping_from = wc_clean( wp_unslash( $_POST['map_from'] ) ); diff --git a/plugins/woocommerce/src/Container.php b/plugins/woocommerce/src/Container.php index 56d3b5b1e98..17ceac62783 100644 --- a/plugins/woocommerce/src/Container.php +++ b/plugins/woocommerce/src/Container.php @@ -32,6 +32,7 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\BatchP use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\LayoutTemplatesServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ComingSoonServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\StatsServiceProvider; +use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ImportExportServiceProvider; /** * PSR11 compliant dependency injection container for WooCommerce. @@ -83,6 +84,7 @@ final class Container { EnginesServiceProvider::class, ComingSoonServiceProvider::class, StatsServiceProvider::class, + ImportExportServiceProvider::class, ); /** diff --git a/plugins/woocommerce/src/Internal/Admin/ImportExport/CSVUploadHelper.php b/plugins/woocommerce/src/Internal/Admin/ImportExport/CSVUploadHelper.php new file mode 100644 index 00000000000..c1759bc5e93 --- /dev/null +++ b/plugins/woocommerce/src/Internal/Admin/ImportExport/CSVUploadHelper.php @@ -0,0 +1,175 @@ +get_import_subdir_name(); + if ( $create ) { + FilesystemUtil::mkdir_p_not_indexable( $upload_dir ); + } + return $upload_dir; + } + + /** + * Handles a CSV file upload. + * + * @param string $import_type Type of upload or context. + * @param string $files_index $_FILES index that contains the file to upload. + * @param array|null $allowed_mime_types List of allowed MIME types. + * @return array { + * Details for the uploaded file. + * + * @type int $id Attachment ID. + * @type string $file Full path to uploaded file. + * } + * + * @throws \Exception In case of error. + */ + public function handle_csv_upload( string $import_type, string $files_index = 'import', ?array $allowed_mime_types = null ): array { + $import_type = sanitize_key( $import_type ); + if ( ! $import_type ) { + throw new \Exception( 'Import type is invalid.' ); + } + + if ( ! $allowed_mime_types ) { + $allowed_mime_types = array( + 'csv' => 'text/csv', + 'txt' => 'text/plain', + ); + } + + $file = $_FILES[ $files_index ] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing + if ( ! isset( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) { + throw new \Exception( esc_html__( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your php.ini or by post_max_size being defined as smaller than upload_max_filesize in php.ini.', 'woocommerce' ) ); + } + + if ( ! function_exists( 'wp_import_handle_upload' ) ) { + require_once ABSPATH . 'wp-admin/includes/import.php'; + } + + // Make sure upload dir exists. + $this->get_import_dir(); + + // Add prefix. + $file['name'] = $import_type . '-' . $file['name']; + + $overrides_callback = function ( $overrides_ ) use ( $allowed_mime_types ) { + $overrides_['test_form'] = false; + $overrides_['test_type'] = true; + $overrides_['mimes'] = $allowed_mime_types; + return $overrides_; + }; + + self::add_filter( 'upload_dir', array( $this, 'override_upload_dir' ) ); + self::add_filter( 'wp_unique_filename', array( $this, 'override_unique_filename' ), 0, 2 ); + self::add_filter( 'wp_handle_upload_overrides', $overrides_callback, 999 ); + self::add_filter( 'wp_handle_upload_prefilter', array( $this, 'remove_txt_from_uploaded_file' ), 0 ); + + $orig_files_import = $_FILES['import'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing + $_FILES['import'] = $file; // wp_import_handle_upload() expects the file to be in 'import'. + + $upload = wp_import_handle_upload(); + + remove_filter( 'upload_dir', array( $this, 'override_upload_dir' ) ); + remove_filter( 'wp_unique_filename', array( $this, 'override_unique_filename' ), 0 ); + remove_filter( 'wp_handle_upload_overrides', $overrides_callback, 999 ); + remove_filter( 'wp_handle_upload_prefilter', array( $this, 'remove_txt_from_uploaded_file' ), 0 ); + + if ( $orig_files_import ) { + $_FILES['import'] = $orig_files_import; + } else { + unset( $_FILES['import'] ); + } + + if ( ! empty( $upload['error'] ) ) { + throw new \Exception( esc_html( $upload['error'] ) ); + } + + if ( ! wc_is_file_valid_csv( $upload['file'], false ) ) { + wp_delete_attachment( $file['id'], true ); + throw new \Exception( esc_html__( 'Invalid file type for a CSV import.', 'woocommerce' ) ); + } + + return $upload; + } + + /** + * Hooked onto 'upload_dir' to override the default upload directory for a CSV upload. + * + * @param array $uploads WP upload dir details. + * @return array + */ + private function override_upload_dir( $uploads ): array { + $new_subdir = '/' . $this->get_import_subdir_name(); + + $uploads['path'] = $uploads['basedir'] . $new_subdir; + $uploads['url'] = $uploads['baseurl'] . $new_subdir; + $uploads['subdir'] = $new_subdir; + + return $uploads; + } + + /** + * Adds a random string to the name of an uploaded CSV file to make it less discoverable. Hooked onto 'wp_unique_filename'. + * + * @param string $filename File name. + * @param string $ext File extension. + * @return string + */ + private function override_unique_filename( string $filename, string $ext ): string { + $length = min( 10, 255 - strlen( $filename ) - 1 ); + if ( 1 < $length ) { + $suffix = strtolower( wp_generate_password( $length, false, false ) ); + $filename = substr( $filename, 0, strlen( $filename ) - strlen( $ext ) ) . '-' . $suffix . $ext; + } + + return $filename; + } + + /** + * `wp_import_handle_upload()` appends .txt to any file name. This function is hooked onto 'wp_handle_upload_prefilter' + * to remove those extra characters. + * + * @param array $file File details in the form of a $_FILES entry. + * @return array Modified file details. + */ + private function remove_txt_from_uploaded_file( array $file ): array { + $file['name'] = substr( $file['name'], 0, -4 ); + return $file; + } +} diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ImportExportServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ImportExportServiceProvider.php new file mode 100644 index 00000000000..58984a41fbf --- /dev/null +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ImportExportServiceProvider.php @@ -0,0 +1,35 @@ +share( CSVUploadHelper::class ); + } +} diff --git a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php index 4bac73265b3..3b91fcbc721 100644 --- a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php +++ b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php @@ -31,6 +31,36 @@ class FilesystemUtil { return $wp_filesystem; } + /** + * Recursively creates a directory (if it doesn't exist) and adds an empty index.html and a .htaccess to prevent + * directory listing. + * + * @since 9.3.0 + * + * @param string $path Directory to create. + * @throws \Exception In case of error. + */ + public static function mkdir_p_not_indexable( string $path ): void { + $wp_fs = self::get_wp_filesystem(); + + if ( $wp_fs->is_dir( $path ) ) { + return; + } + + if ( ! wp_mkdir_p( $path ) ) { + throw new \Exception( esc_html( sprintf( 'Could not create directory: %s.', wp_basename( $path ) ) ) ); + } + + $files = array( + '.htaccess' => 'deny from all', + 'index.html' => '', + ); + + foreach ( $files as $name => $content ) { + $wp_fs->put_contents( trailingslashit( $path ) . $name, $content ); + } + } + /** * Wrapper to initialize the WP filesystem with defined credentials if they are available. * diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js index 103d50fd8f6..507568812a0 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js @@ -89,8 +89,7 @@ const productCategories = [ ]; const productAttributes = [ 'Color', 'Size' ]; -const errorMessage = - 'Invalid file type. The importer supports CSV and TXT file formats.'; +const errorMessage = 'File is empty. Please upload something more substantial.'; test.describe.serial( 'Import Products from a CSV file', From 0eb4383918aea3fbd956495c70ac4c349679e89e Mon Sep 17 00:00:00 2001 From: jonathansadowski Date: Fri, 13 Sep 2024 14:20:19 -0500 Subject: [PATCH 02/36] Update nvm instructions in README.md (#51240) * Update nvm instructions in README.md * Update getting started instructions for nvm install * Update docs manifest --- README.md | 4 ++-- docs/docs-manifest.json | 4 ++-- .../getting-started/development-environment.md | 18 ++++++------------ 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a0dc78caa81..4ff249daa69 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ To get up and running within the WooCommerce Monorepo, you will need to make sur Once you've installed all of the prerequisites, you can run the following commands to get everything working. ```bash -# Ensure that you're using the correct version of Node -nvm use +# Ensure that correct version of Node is installed and being used +nvm install # Install the PHP and Composer dependencies for all of the plugins, packages, and tools pnpm install # Build all of the plugins, packages, and tools in the monorepo diff --git a/docs/docs-manifest.json b/docs/docs-manifest.json index f7579ca4cef..45cec2a5a3f 100644 --- a/docs/docs-manifest.json +++ b/docs/docs-manifest.json @@ -874,7 +874,7 @@ "menu_title": "Development environment setup", "tags": "tutorial, setup", "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/getting-started/development-environment.md", - "hash": "9e471d3f44a882fe61dcad9e5207d51b280a7220aae1bf6e4ae1fbdd68b7e3d4", + "hash": "bf5d77349ea64d1b8e19fe6b7472be35ed92406c5aafe677ce92363fb13f94d4", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/getting-started/development-environment.md", "id": "9080572a3904349c44c565ca7e1bef1212c58757" }, @@ -1804,5 +1804,5 @@ "categories": [] } ], - "hash": "77c102c35a45b0681e7b70def9d639d764e4e5068121c2ef4dd23477c0f8784c" + "hash": "dfe48a2a48d383c1f4e5b5bb4258d369dd4e80ac8582462b16bbfedd879cb0bf" } \ No newline at end of file diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index 0701be60fc1..989587d4153 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -80,22 +80,16 @@ git clone https://github.com/woocommerce/woocommerce.git cd woocommerce ``` -### Activate the required Node version +### Install and Activate the required Node version ```sh -nvm use -Found '/path/to/woocommerce/.nvmrc' with version -Now using node v12.21.0 (npm v6.14.11) +nvm install +Found '/path/to/woocommerce/.nvmrc' with version +v20.17.0 is already installed. +Now using node v20.17.0 (npm v10.8.2) ``` -Note: if you don't have the required version of Node installed, NVM will alert you so you can install it: - -```sh -Found '/path/to/woocommerce/.nvmrc' with version -N/A: version "v12 -> N/A" is not yet installed. - -You need to run "nvm install v12" to install it before using it. -``` +Note: if you don't have the required version of Node installed, NVM will install it. ### Install dependencies From ae207242109f0b5e79fa82189a49500c8ff283d6 Mon Sep 17 00:00:00 2001 From: Fernando Marichal Date: Fri, 13 Sep 2024 18:52:12 -0300 Subject: [PATCH 03/36] Add inspector controls to Product Search block (#51247) * Add inspector controls to product search * Add changelog * Move inspector panel to Styles * Remove not used Fragment * improve code * Rename enum * Rename type ProductSearchBlockProps * Use PositionOptions constant * Change hook namespace --- .../js/blocks/product-search/constants.ts | 10 ++ .../assets/js/blocks/product-search/index.tsx | 38 ++++- .../product-search/inspector-controls.tsx | 156 ++++++++++++++++++ .../assets/js/blocks/product-search/types.ts | 23 +++ .../assets/js/blocks/product-search/utils.ts | 75 +++++++++ ...-47890_inspector_control_to_product_search | 4 + 6 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/product-search/constants.ts create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/product-search/inspector-controls.tsx create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/product-search/types.ts create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/product-search/utils.ts create mode 100644 plugins/woocommerce/changelog/add-47890_inspector_control_to_product_search diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-search/constants.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-search/constants.ts new file mode 100644 index 00000000000..ae42399cf3d --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-search/constants.ts @@ -0,0 +1,10 @@ +export const SEARCH_BLOCK_NAME = 'core/search'; +export const SEARCH_VARIATION_NAME = 'woocommerce/product-search'; + +export enum PositionOptions { + OUTSIDE = 'button-outside', + INSIDE = 'button-inside', + NO_BUTTON = 'no-button', + BUTTON_ONLY = 'button-only', + INPUT_AND_BUTTON = 'input-and-button', +} diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-search/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-search/index.tsx index 9691e2cdf9a..89bd2ac4dac 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-search/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-search/index.tsx @@ -2,6 +2,7 @@ /** * External dependencies */ +import { addFilter } from '@wordpress/hooks'; import { store as blockEditorStore, Warning } from '@wordpress/block-editor'; import { useDispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; @@ -9,6 +10,7 @@ import { Icon, search } from '@wordpress/icons'; import { getSettingWithCoercion } from '@woocommerce/settings'; import { isBoolean } from '@woocommerce/types'; import { Button } from '@wordpress/components'; +import type { Block as BlockType } from '@wordpress/blocks'; import { // @ts-ignore waiting for @types/wordpress__blocks update registerBlockVariation, @@ -21,8 +23,10 @@ import { */ import './style.scss'; import './editor.scss'; +import { withProductSearchControls } from './inspector-controls'; import Block from './block'; import Edit from './edit'; +import { SEARCH_BLOCK_NAME, SEARCH_VARIATION_NAME } from './constants'; const isBlockVariationAvailable = getSettingWithCoercion( 'isBlockVariationAvailable', @@ -71,6 +75,7 @@ const PRODUCT_SEARCH_ATTRIBUTES = { query: { post_type: 'product', }, + namespace: SEARCH_VARIATION_NAME, }; const DeprecatedBlockEdit = ( { clientId }: { clientId: string } ) => { @@ -115,7 +120,7 @@ const DeprecatedBlockEdit = ( { clientId }: { clientId: string } ) => { ); }; -registerBlockType( 'woocommerce/product-search', { +registerBlockType( SEARCH_VARIATION_NAME, { title: __( 'Product Search', 'woocommerce' ), apiVersion: 3, icon: { @@ -146,7 +151,7 @@ registerBlockType( 'woocommerce/product-search', { isMatch: ( { idBase, instance } ) => idBase === 'woocommerce_product_search' && !! instance?.raw, transform: ( { instance } ) => - createBlock( 'woocommerce/product-search', { + createBlock( SEARCH_VARIATION_NAME, { label: instance.raw.title || PRODUCT_SEARCH_ATTRIBUTES.label, @@ -172,9 +177,31 @@ registerBlockType( 'woocommerce/product-search', { }, } ); +function registerProductSearchNamespace( props: BlockType, blockName: string ) { + if ( blockName === 'core/search' ) { + // Gracefully handle if settings.attributes is undefined. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore -- We need this because `attributes` is marked as `readonly` + props.attributes = { + ...props.attributes, + namespace: { + type: 'string', + }, + }; + } + + return props; +} + +addFilter( + 'blocks.registerBlockType', + SEARCH_VARIATION_NAME, + registerProductSearchNamespace +); + if ( isBlockVariationAvailable ) { registerBlockVariation( 'core/search', { - name: 'woocommerce/product-search', + name: SEARCH_VARIATION_NAME, title: __( 'Product Search', 'woocommerce' ), icon: { src: ( @@ -199,4 +226,9 @@ if ( isBlockVariationAvailable ) { ), attributes: PRODUCT_SEARCH_ATTRIBUTES, } ); + addFilter( + 'editor.BlockEdit', + SEARCH_BLOCK_NAME, + withProductSearchControls + ); } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-search/inspector-controls.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-search/inspector-controls.tsx new file mode 100644 index 00000000000..3212dd9e76e --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-search/inspector-controls.tsx @@ -0,0 +1,156 @@ +/** + * External dependencies + */ +import { type ElementType, useEffect, useState } from '@wordpress/element'; +import { EditorBlock } from '@woocommerce/types'; +import { __ } from '@wordpress/i18n'; +import { InspectorControls } from '@wordpress/block-editor'; +import { + PanelBody, + RadioControl, + ToggleControl, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Ignoring because `__experimentalToggleGroupControl` is not yet in the type definitions. + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControl as ToggleGroupControl, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Ignoring because `__experimentalToggleGroupControlOption` is not yet in the type definitions. + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControlOption as ToggleGroupControlOption, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { + getInputAndButtonOption, + getSelectedRadioControlOption, + isInputAndButtonOption, + isWooSearchBlockVariation, +} from './utils'; +import { ButtonPositionProps, ProductSearchBlockProps } from './types'; +import { PositionOptions } from './constants'; + +const ProductSearchControls = ( props: ProductSearchBlockProps ) => { + const { attributes, setAttributes } = props; + const { buttonPosition, buttonUseIcon, showLabel } = attributes; + const [ initialPosition, setInitialPosition ] = + useState< ButtonPositionProps >( buttonPosition ); + + useEffect( () => { + if ( + isInputAndButtonOption( buttonPosition ) && + initialPosition !== buttonPosition + ) { + setInitialPosition( buttonPosition ); + } + }, [ buttonPosition ] ); + + return ( + + + & + PositionOptions.INPUT_AND_BUTTON + ) => { + if ( selected !== PositionOptions.INPUT_AND_BUTTON ) { + setAttributes( { + buttonPosition: selected, + } ); + } else { + const newButtonPosition = + getInputAndButtonOption( initialPosition ); + setAttributes( { + buttonPosition: newButtonPosition, + } ); + } + } } + /> + { buttonPosition !== PositionOptions.NO_BUTTON && ( + <> + { buttonPosition !== PositionOptions.BUTTON_ONLY && ( + { + setAttributes( { + buttonPosition: value, + } ); + } } + value={ getInputAndButtonOption( + buttonPosition + ) } + > + + + + ) } + { + setAttributes( { + buttonUseIcon: value, + } ); + } } + value={ buttonUseIcon } + > + + + + + ) } + + setAttributes( { + showLabel: showInputLabel, + } ) + } + /> + + + ); +}; + +export const withProductSearchControls = + < T extends EditorBlock< T > >( BlockEdit: ElementType ) => + ( props: ProductSearchBlockProps ) => { + return isWooSearchBlockVariation( props ) ? ( + <> + + + + ) : ( + + ); + }; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-search/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-search/types.ts new file mode 100644 index 00000000000..290523ae727 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-search/types.ts @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import type { EditorBlock } from '@woocommerce/types'; + +export type ButtonPositionProps = + | 'button-outside' + | 'button-inside' + | 'no-button' + | 'button-only'; + +export interface SearchBlockAttributes { + buttonPosition: ButtonPositionProps; + buttonText?: string; + buttonUseIcon: boolean; + isSearchFieldHidden: boolean; + label?: string; + namespace?: string; + placeholder?: string; + showLabel: boolean; +} + +export type ProductSearchBlockProps = EditorBlock< SearchBlockAttributes >; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-search/utils.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-search/utils.ts new file mode 100644 index 00000000000..5b70f82a27a --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-search/utils.ts @@ -0,0 +1,75 @@ +/** + * Internal dependencies + */ +import { + PositionOptions, + SEARCH_BLOCK_NAME, + SEARCH_VARIATION_NAME, +} from './constants'; +import { ButtonPositionProps, ProductSearchBlockProps } from './types'; + +/** + * Identifies if a block is a Search block variation from our conventions + * + * We are extending Gutenberg's core Search block with our variations, and + * also adding extra namespaced attributes. If those namespaced attributes + * are present, we can be fairly sure it is our own registered variation. + * + * @param {ProductSearchBlockProps} block - A WooCommerce block. + */ +export function isWooSearchBlockVariation( block: ProductSearchBlockProps ) { + return ( + block.name === SEARCH_BLOCK_NAME && + block.attributes?.namespace === SEARCH_VARIATION_NAME + ); +} + +/** + * Checks if the given button position is a valid option for input and button placement. + * + * The function verifies if the provided `buttonPosition` matches one of the predefined + * values for placing a button either inside or outside an input field. + * + * @param {string} buttonPosition - The position of the button to check. + */ +export function isInputAndButtonOption( buttonPosition: string ): boolean { + return ( + buttonPosition === 'button-outside' || + buttonPosition === 'button-inside' + ); +} + +/** + * Returns the option for the selected button position + * + * Based on the provided `buttonPosition`, the function returns a predefined option + * if the position is valid for input and button placement. If the position is not + * one of the predefined options, it returns the original `buttonPosition`. + * + * @param {string} buttonPosition - The position of the button to evaluate. + */ +export function getSelectedRadioControlOption( + buttonPosition: string +): string { + if ( isInputAndButtonOption( buttonPosition ) ) { + return PositionOptions.INPUT_AND_BUTTON; + } + return buttonPosition; +} + +/** + * Returns the appropriate option for input and button placement based on the given value + * + * This function checks if the provided `value` is a valid option for placing a button either + * inside or outside an input field. If the `value` is valid, it is returned as is. If the `value` + * is not valid, the function returns a default option. + * + * @param {ButtonPositionProps} value - The position of the button to evaluate. + */ +export function getInputAndButtonOption( value: ButtonPositionProps ) { + if ( isInputAndButtonOption( value ) ) { + return value; + } + // The default value is 'inside' for input and button. + return PositionOptions.OUTSIDE; +} diff --git a/plugins/woocommerce/changelog/add-47890_inspector_control_to_product_search b/plugins/woocommerce/changelog/add-47890_inspector_control_to_product_search new file mode 100644 index 00000000000..351aa293cfd --- /dev/null +++ b/plugins/woocommerce/changelog/add-47890_inspector_control_to_product_search @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add inspector controls to Product Search block #51247 From 63d93d73551a1cc908e5385818796c9d042eca2f Mon Sep 17 00:00:00 2001 From: Narendra Sishodiya <32844880+narenin@users.noreply.github.com> Date: Sun, 15 Sep 2024 20:51:55 +0530 Subject: [PATCH 04/36] Implemented PHPCS Fixes in OrdersTableQuery.php and ProductQuery.php (#51281) * Implemented PHPCS Fixes in OrdersTableQuery.php and ProductQuery.php * Reverted gmdate() to date() * Add changelog * Fixed linting issue in Update OrdersTableQuery.php --------- Co-authored-by: Alex Florisca --- .../woocommerce/changelog/51281-phpcs-fixes | 4 ++ .../DataStores/Orders/OrdersTableQuery.php | 17 ++--- .../src/StoreApi/Utilities/ProductQuery.php | 72 +++++++++---------- 3 files changed, 49 insertions(+), 44 deletions(-) create mode 100644 plugins/woocommerce/changelog/51281-phpcs-fixes diff --git a/plugins/woocommerce/changelog/51281-phpcs-fixes b/plugins/woocommerce/changelog/51281-phpcs-fixes new file mode 100644 index 00000000000..9db48f6d016 --- /dev/null +++ b/plugins/woocommerce/changelog/51281-phpcs-fixes @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Fix PHPCS warnings in OrdersTableQuery.php and ProductQuery.php diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php index e20a5aa2287..f448fe83551 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php @@ -336,11 +336,11 @@ class OrdersTableQuery { * Generates a `WP_Date_Query` compatible query from a given date. * YYYY-MM-DD queries have 'day' precision for backwards compatibility. * - * @param mixed $date The date. Can be a {@see \WC_DateTime}, a timestamp or a string. + * @param mixed $date The date. Can be a {@see \WC_DateTime}, a timestamp or a string. * @return array An array with keys 'year', 'month', 'day' and possibly 'hour', 'minute' and 'second'. */ private function date_to_date_query_arg( $date ): array { - $result = array( + $result = array( 'year' => '', 'month' => '', 'day' => '', @@ -373,10 +373,12 @@ class OrdersTableQuery { /** * Returns UTC-based date query arguments for a combination of local time dates and a date shorthand operator. * - * @param array $dates_raw Array of dates (in local time) to use in combination with the operator. + * @param array $dates_raw Array of dates (in local time) to use in combination with the operator. * @param string $operator One of the operators supported by date queries (<, <=, =, ..., >, >=). * @return array Partial date query arg with relevant dates now UTC-based. * + * @throws \Exception If an invalid date shorthand operator is specified. + * * @since 8.2.0 */ private function local_time_to_gmt_date_query( $dates_raw, $operator ) { @@ -387,7 +389,7 @@ class OrdersTableQuery { $raw_date = is_numeric( $raw_date ) ? $raw_date : strtotime( get_gmt_from_date( date( 'Y-m-d', strtotime( $raw_date ) ) ) ); } - $date1 = end( $dates_raw ); + $date1 = end( $dates_raw ); switch ( $operator ) { case '>': @@ -410,9 +412,9 @@ class OrdersTableQuery { 'inclusive' => true, ), array( - 'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ), - 'inclusive' => false, - ) + 'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ), + 'inclusive' => false, + ), ); break; case '<=': @@ -474,7 +476,6 @@ class OrdersTableQuery { foreach ( $date_keys as $date_key ) { $is_local = in_array( $date_key, $local_date_keys, true ); $date_value = $this->args[ $date_key ]; - $operator = '='; $dates_raw = array(); $dates = array(); diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php index 25f915d2862..3fce3214ab0 100644 --- a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php +++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php @@ -16,7 +16,7 @@ class ProductQuery { * @return array */ public function prepare_objects_query( $request ) { - $args = [ + $args = array( 'offset' => $request['offset'], 'order' => $request['order'], 'orderby' => $request['orderby'], @@ -31,17 +31,17 @@ class ProductQuery { 'fields' => 'ids', 'ignore_sticky_posts' => true, 'post_status' => 'publish', - 'date_query' => [], + 'date_query' => array(), 'post_type' => 'product', - ]; + ); // If searching for a specific SKU or slug, allow any post type. if ( ! empty( $request['sku'] ) || ! empty( $request['slug'] ) ) { - $args['post_type'] = [ 'product', 'product_variation' ]; + $args['post_type'] = array( 'product', 'product_variation' ); } // Taxonomy query to filter products by type, category, tag, shipping class, and attribute. - $tax_query = []; + $tax_query = array(); // Filter product type by slug. if ( ! empty( $request['type'] ) ) { @@ -49,11 +49,11 @@ class ProductQuery { $args['post_type'] = 'product_variation'; } else { $args['post_type'] = 'product'; - $tax_query[] = [ + $tax_query[] = array( 'taxonomy' => 'product_type', 'field' => 'slug', 'terms' => $request['type'], - ]; + ); } } @@ -77,12 +77,12 @@ class ProductQuery { } // Set custom args to handle later during clauses. - $custom_keys = [ + $custom_keys = array( 'sku', 'min_price', 'max_price', 'stock_status', - ]; + ); foreach ( $custom_keys as $key ) { if ( ! empty( $request[ $key ] ) ) { @@ -90,11 +90,11 @@ class ProductQuery { } } - $operator_mapping = [ + $operator_mapping = array( 'in' => 'IN', 'not_in' => 'NOT IN', 'and' => 'AND', - ]; + ); // Gets all registered product taxonomies and prefixes them with `tax_`. // This is needed to avoid situations where a user registers a new product taxonomy with the same name as default field. @@ -107,10 +107,10 @@ class ProductQuery { ); // Map between taxonomy name and arg key. - $default_taxonomies = [ + $default_taxonomies = array( 'product_cat' => 'category', 'product_tag' => 'tag', - ]; + ); $taxonomies = array_merge( $all_product_taxonomies, $default_taxonomies ); @@ -118,18 +118,18 @@ class ProductQuery { foreach ( $taxonomies as $taxonomy => $key ) { if ( ! empty( $request[ $key ] ) ) { $operator = $request->get_param( $key . '_operator' ) && isset( $operator_mapping[ $request->get_param( $key . '_operator' ) ] ) ? $operator_mapping[ $request->get_param( $key . '_operator' ) ] : 'IN'; - $tax_query[] = [ + $tax_query[] = array( 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $request[ $key ], 'operator' => $operator, - ]; + ); } } // Filter by attributes. if ( ! empty( $request['attributes'] ) ) { - $att_queries = []; + $att_queries = array(); foreach ( $request['attributes'] as $attribute ) { if ( empty( $attribute['term_id'] ) && empty( $attribute['slug'] ) ) { @@ -137,22 +137,22 @@ class ProductQuery { } if ( in_array( $attribute['attribute'], wc_get_attribute_taxonomy_names(), true ) ) { $operator = isset( $attribute['operator'], $operator_mapping[ $attribute['operator'] ] ) ? $operator_mapping[ $attribute['operator'] ] : 'IN'; - $att_queries[] = [ + $att_queries[] = array( 'taxonomy' => $attribute['attribute'], 'field' => ! empty( $attribute['term_id'] ) ? 'term_id' : 'slug', 'terms' => ! empty( $attribute['term_id'] ) ? $attribute['term_id'] : $attribute['slug'], 'operator' => $operator, - ]; + ); } } if ( 1 < count( $att_queries ) ) { // Add relation arg when using multiple attributes. $relation = $request->get_param( 'attribute_relation' ) && isset( $operator_mapping[ $request->get_param( 'attribute_relation' ) ] ) ? $operator_mapping[ $request->get_param( 'attribute_relation' ) ] : 'IN'; - $tax_query[] = [ + $tax_query[] = array( 'relation' => $relation, $att_queries, - ]; + ); } else { $tax_query = array_merge( $tax_query, $att_queries ); } @@ -176,12 +176,12 @@ class ProductQuery { // Filter featured. if ( is_bool( $request['featured'] ) ) { - $args['tax_query'][] = [ + $args['tax_query'][] = array( 'taxonomy' => 'product_visibility', 'field' => 'name', 'terms' => 'featured', 'operator' => true === $request['featured'] ? 'IN' : 'NOT IN', - ]; + ); } // Filter by on sale products. @@ -190,7 +190,7 @@ class ProductQuery { $on_sale_ids = wc_get_product_ids_on_sale(); // Use 0 when there's no on sale products to avoid return all products. - $on_sale_ids = empty( $on_sale_ids ) ? [ 0 ] : $on_sale_ids; + $on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids; $args[ $on_sale_key ] += $on_sale_ids; } @@ -203,25 +203,25 @@ class ProductQuery { $exclude_from_catalog = 'search' === $catalog_visibility ? '' : 'exclude-from-catalog'; $exclude_from_search = 'catalog' === $catalog_visibility ? '' : 'exclude-from-search'; - $args['tax_query'][] = [ + $args['tax_query'][] = array( 'taxonomy' => 'product_visibility', 'field' => 'name', - 'terms' => [ $exclude_from_catalog, $exclude_from_search ], + 'terms' => array( $exclude_from_catalog, $exclude_from_search ), 'operator' => 'hidden' === $catalog_visibility ? 'AND' : 'NOT IN', 'rating_filter' => true, - ]; + ); } if ( $rating ) { - $rating_terms = []; + $rating_terms = array(); foreach ( $rating as $value ) { $rating_terms[] = 'rated-' . $value; } - $args['tax_query'][] = [ + $args['tax_query'][] = array( 'taxonomy' => 'product_visibility', 'field' => 'name', 'terms' => $rating_terms, - ]; + ); } $orderby = $request->get_param( 'orderby' ); @@ -283,7 +283,7 @@ class ProductQuery { public function get_results( $request ) { $query_args = $this->prepare_objects_query( $request ); - add_filter( 'posts_clauses', [ $this, 'add_query_clauses' ], 10, 2 ); + add_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10, 2 ); $query = new \WP_Query(); $results = $query->query( $query_args ); @@ -297,13 +297,13 @@ class ProductQuery { $total_posts = $count_query->found_posts; } - remove_filter( 'posts_clauses', [ $this, 'add_query_clauses' ], 10 ); + remove_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10 ); - return [ + return array( 'results' => $results, 'total' => (int) $total_posts, 'pages' => $query->query_vars['posts_per_page'] > 0 ? (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ) : 1, - ]; + ); } /** @@ -315,11 +315,11 @@ class ProductQuery { public function get_objects( $request ) { $results = $this->get_results( $request ); - return [ + return array( 'objects' => array_map( 'wc_get_product', $results['results'] ), 'total' => $results['total'], 'pages' => $results['pages'], - ]; + ); } /** @@ -442,7 +442,7 @@ class ProductQuery { return ''; } - $or_queries = []; + $or_queries = array(); // We need to adjust the filter for each possible tax class and combine the queries into one. foreach ( $product_tax_classes as $tax_class ) { From d397e39c751611151d052fcd8698367e18af1a41 Mon Sep 17 00:00:00 2001 From: Vladimir Reznichenko Date: Mon, 16 Sep 2024 09:05:29 +0200 Subject: [PATCH 05/36] [dev] Update code owners files: initial ownership for Flux team (#51260) --- CODEOWNERS | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 614fa2f46be..532335b71de 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1,14 @@ -/.github/ @woocommerce/atlas +# Monorepo CI and package managers manifests. +/.github/ @woocommerce/flux +**/composer.json @woocommerce/flux +**/package.json @woocommerce/flux + +# Monorepo tooling folders. +/bin/ @woocommerce/flux +/tools/ @woocommerce/flux +/packages/js/eslint-plugin/ @woocommerce/flux +/packages/js/dependency-extraction-webpack-plugin/ @woocommerce/flux + +# Files in root of repository +/.* @woocommerce/flux +/*.* @woocommerce/flux From 79b8a4015737ea4c7fe32ca2023216e44ed14849 Mon Sep 17 00:00:00 2001 From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:11:15 +0100 Subject: [PATCH 06/36] Update nighty build workflow (#51328) --- .github/workflows/nightly-builds.yml | 57 ++++++++++++++++------------ 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/.github/workflows/nightly-builds.yml b/.github/workflows/nightly-builds.yml index 6a3e22fa123..71ce84bf288 100644 --- a/.github/workflows/nightly-builds.yml +++ b/.github/workflows/nightly-builds.yml @@ -4,23 +4,25 @@ on: - cron: '0 0 * * *' # Run at 12 AM UTC. workflow_dispatch: -permissions: {} +env: + SOURCE_REF: trunk + TARGET_REF: nightly + RELEASE_ID: 25945111 + +permissions: { } jobs: build: if: github.repository_owner == 'woocommerce' name: Nightly builds - strategy: - fail-fast: false - matrix: - build: [trunk] + runs-on: ubuntu-20.04 permissions: contents: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: - ref: ${{ matrix.build }} + ref: ${{ env.SOURCE_REF }} - name: Setup WooCommerce Monorepo uses: ./.github/actions/setup-woocommerce-monorepo @@ -31,26 +33,31 @@ jobs: working-directory: plugins/woocommerce run: bash bin/build-zip.sh - - name: Deploy nightly build - uses: WebFreak001/deploy-nightly@v1.1.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload nightly build + uses: WebFreak001/deploy-nightly@46ecbabd7fad70d3e7d2c97fe8cd54e7a52e215b #v3.2.0 with: - upload_url: https://uploads.github.com/repos/${{ github.repository }}/releases/25945111/assets{?name,label} - release_id: 25945111 + token: ${{ secrets.GITHUB_TOKEN }} + upload_url: https://uploads.github.com/repos/${{ github.repository }}/releases/${{ env.RELEASE_ID }}/assets{?name,label} + release_id: ${{ env.RELEASE_ID }} asset_path: plugins/woocommerce/woocommerce.zip - asset_name: woocommerce-${{ matrix.build }}-nightly.zip + asset_name: woocommerce-${{ env.SOURCE_REF }}-nightly.zip asset_content_type: application/zip max_releases: 1 - update: - name: Update nightly tag commit ref - runs-on: ubuntu-20.04 - permissions: - contents: write - steps: - - name: Update nightly tag - uses: richardsimko/github-tag-action@v1.0.5 + + - name: Update nightly tag commit ref + uses: actions/github-script@v7 with: - tag_name: nightly - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const sourceRef = process.env.SOURCE_REF; + const targetRef = process.env.TARGET_REF; + const branchData = await github.rest.repos.getBranch({ + ...context.repo, + branch: sourceRef, + }); + + await github.rest.git.updateRef({ + ...context.repo, + ref: `tags/${ targetRef }`, + sha: branchData.data.commit.sha, + }); From 77a17e48b77dc84193867c570b2db22b8e4340da Mon Sep 17 00:00:00 2001 From: Seghir Nadir Date: Mon, 16 Sep 2024 11:22:38 +0200 Subject: [PATCH 07/36] update Mongolia postcode to be 5 digits instead of 6 in Checkout block (#50279) * update Mongolia postcode to be 5 digits instead of 6 * Add changefile(s) from automation for the following project(s): woocommerce-blocks * update validation package and expand mongolia checks * Add changefile(s) from automation for the following project(s): woocommerce-blocks * update lock * fix lock again --------- Co-authored-by: github-actions --- plugins/woocommerce-blocks/package.json | 2 +- .../packages/checkout/utils/validation/is-postcode.ts | 1 + .../changelog/50279-fix-mongolia-postcode-5 | 4 ++++ pnpm-lock.yaml | 10 +++++----- 4 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 plugins/woocommerce/changelog/50279-fix-mongolia-postcode-5 diff --git a/plugins/woocommerce-blocks/package.json b/plugins/woocommerce-blocks/package.json index cd63f8a5ca4..45f4c6d706b 100644 --- a/plugins/woocommerce-blocks/package.json +++ b/plugins/woocommerce-blocks/package.json @@ -288,7 +288,7 @@ "fast-deep-equal": "^3.1.3", "fast-sort": "^3.4.0", "html-react-parser": "3.0.4", - "postcode-validator": "3.8.15", + "postcode-validator": "3.9.2", "preact": "^10.19.3", "prop-types": "^15.8.1", "react-number-format": "4.9.3", diff --git a/plugins/woocommerce-blocks/packages/checkout/utils/validation/is-postcode.ts b/plugins/woocommerce-blocks/packages/checkout/utils/validation/is-postcode.ts index ec2ca97a1c3..2ab21d238b9 100644 --- a/plugins/woocommerce-blocks/packages/checkout/utils/validation/is-postcode.ts +++ b/plugins/woocommerce-blocks/packages/checkout/utils/validation/is-postcode.ts @@ -13,6 +13,7 @@ const CUSTOM_REGEXES = new Map< string, RegExp >( [ [ 'JP', /^([0-9]{3})([-]?)([0-9]{4})$/ ], [ 'KH', /^[0-9]{6}$/ ], // Cambodia (6-digit postal code). [ 'LI', /^(94[8-9][0-9])$/ ], + [ 'MN', /^[0-9]{5}(-[0-9]{4})?$/ ], // Mongolia (5-digit postal code or 5-digit postal code followed by a hyphen and 4-digit postal code). [ 'NI', /^[1-9]{1}[0-9]{4}$/ ], // Nicaragua (5-digit postal code) [ 'NL', /^([1-9][0-9]{3})(\s?)(?!SA|SD|SS)[A-Z]{2}$/i ], [ 'SI', /^([1-9][0-9]{3})$/ ], diff --git a/plugins/woocommerce/changelog/50279-fix-mongolia-postcode-5 b/plugins/woocommerce/changelog/50279-fix-mongolia-postcode-5 new file mode 100644 index 00000000000..b6f3ac11940 --- /dev/null +++ b/plugins/woocommerce/changelog/50279-fix-mongolia-postcode-5 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Adjust Mongolia postcode validation to be 5 digits or 5 digits followed by 4 digits. \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a972c8f247..296a64d656a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4025,8 +4025,8 @@ importers: specifier: 3.0.4 version: 3.0.4(react@18.3.1) postcode-validator: - specifier: 3.8.15 - version: 3.8.15 + specifier: 3.9.2 + version: 3.9.2 preact: specifier: ^10.19.3 version: 10.19.3 @@ -20623,8 +20623,8 @@ packages: resolution: {integrity: sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==} engines: {node: '>=0.10.0'} - postcode-validator@3.8.15: - resolution: {integrity: sha512-B2oeZ4E9D7JBHk0GEo0lv9aqHGd6vv+VsWoTG6Jt0tMtc5RUzSXOSQixBZgAAn4A/Dbajp8GbwzMtUkkyYw2Ig==} + postcode-validator@3.9.2: + resolution: {integrity: sha512-C+oaXif+z+mAN1EWDZG/EM2dnrUxRQR0gpMq8VeLSZwHuT3Bqo4hR3+k7OaEevPl0VK/7W27JwEQb24CbdtzuQ==} postcss-calc@7.0.5: resolution: {integrity: sha512-1tKHutbGtLtEZF6PT4JSihCHfIVldU72mZ8SdZHIYriIZ9fh9k9aWSppaT8rHsyI3dX+KSR+W+Ix9BMY3AODrg==} @@ -56933,7 +56933,7 @@ snapshots: posix-character-classes@0.1.1: {} - postcode-validator@3.8.15: {} + postcode-validator@3.9.2: {} postcss-calc@7.0.5: dependencies: From cebdcc61d0d0cc11a2fe6c51c3ed03516dfe23e7 Mon Sep 17 00:00:00 2001 From: Ivan Stojadinov Date: Mon, 16 Sep 2024 13:56:44 +0200 Subject: [PATCH 08/36] [e2e] External - Expand WPCOM suite, part 1 (#51303) * Expand WPCOM suite * Skip core-profiler.spec.js on WPCOM - no "Coming soon" * Skip `Analytics-related tests` on WPCOM - different sums * Skip `Marketing Overview page have relevant content` - no content on WPCOM * Payment setup task - make Save button more unique * Include more tests in playwright.config.js * Skip `Store owner can skip the core profiler` * Add changefile(s) from automation for the following project(s): woocommerce * Make "Get paid" more unique --------- Co-authored-by: github-actions --- .../51303-e2e-external-expand-wpcom-suite | 4 ++ .../envs/default-wpcom/playwright.config.js | 4 ++ .../activate-and-setup/core-profiler.spec.js | 4 +- .../admin-analytics/analytics-data.spec.js | 9 ++- .../tests/admin-marketing/overview.spec.js | 68 ++++++++++--------- .../e2e-pw/tests/admin-tasks/payment.spec.js | 4 +- 6 files changed, 56 insertions(+), 37 deletions(-) create mode 100644 plugins/woocommerce/changelog/51303-e2e-external-expand-wpcom-suite diff --git a/plugins/woocommerce/changelog/51303-e2e-external-expand-wpcom-suite b/plugins/woocommerce/changelog/51303-e2e-external-expand-wpcom-suite new file mode 100644 index 00000000000..975d531637f --- /dev/null +++ b/plugins/woocommerce/changelog/51303-e2e-external-expand-wpcom-suite @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Expand the e2e suite we're running on WPCOM. \ No newline at end of file diff --git a/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js b/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js index 08977a04e71..276458fa570 100644 --- a/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js +++ b/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js @@ -9,6 +9,10 @@ config = { use: { ...devices[ 'Desktop Chrome' ] }, testMatch: [ '**/basic.spec.js', + '**/activate-and-setup/**/*.spec.js', + '**/admin-analytics/**/*.spec.js', + '**/admin-marketing/**/*.spec.js', + '**/admin-tasks/**/*.spec.js', '**/shopper/**/*.spec.js', '**/api-tests/**/*.test.js', ], diff --git a/plugins/woocommerce/tests/e2e-pw/tests/activate-and-setup/core-profiler.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/activate-and-setup/core-profiler.spec.js index f0cf8016e40..a4679791f03 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/activate-and-setup/core-profiler.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/activate-and-setup/core-profiler.spec.js @@ -3,7 +3,7 @@ const { setOption } = require( '../../utils/options' ); test.describe( 'Store owner can complete the core profiler', - { tag: '@skip-on-default-pressable' }, + { tag: [ '@skip-on-default-pressable', '@skip-on-default-wpcom' ] }, () => { test.use( { storageState: process.env.ADMINSTATE } ); @@ -450,7 +450,7 @@ test.describe( test.describe( 'Store owner can skip the core profiler', - { tag: '@skip-on-default-pressable' }, + { tag: [ '@skip-on-default-pressable', '@skip-on-default-wpcom' ] }, () => { test.use( { storageState: process.env.ADMINSTATE } ); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/admin-analytics/analytics-data.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/admin-analytics/analytics-data.spec.js index 6cfd1d35fd2..9d8ebd604a9 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/admin-analytics/analytics-data.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/admin-analytics/analytics-data.spec.js @@ -25,7 +25,14 @@ const test = baseTest.extend( { test.describe( 'Analytics-related tests', - { tag: [ '@payments', '@services', '@skip-on-default-pressable' ] }, + { + tag: [ + '@payments', + '@services', + '@skip-on-default-pressable', + '@skip-on-default-wpcom', + ], + }, () => { let categoryIds, productIds, orderIds, setupPage; diff --git a/plugins/woocommerce/tests/e2e-pw/tests/admin-marketing/overview.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/admin-marketing/overview.spec.js index 2ababaf97ec..773f160caf2 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/admin-marketing/overview.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/admin-marketing/overview.spec.js @@ -15,40 +15,44 @@ test.describe( 'Marketing page', () => { ).toBeVisible(); } ); - test( 'Marketing Overview page have relevant content', async ( { - page, - } ) => { - // Go to the Marketing page. - await page.goto( 'wp-admin/admin.php?page=wc-admin&path=%2Fmarketing' ); + test( + 'Marketing Overview page have relevant content', + { tag: '@skip-on-default-wpcom' }, + async ( { page } ) => { + // Go to the Marketing page. + await page.goto( + 'wp-admin/admin.php?page=wc-admin&path=%2Fmarketing' + ); - // Heading should be overview - await expect( - page.getByRole( 'heading', { name: 'Overview' } ) - ).toBeVisible(); + // Heading should be overview + await expect( + page.getByRole( 'heading', { name: 'Overview' } ) + ).toBeVisible(); - // Sections present - await expect( - page.getByText( 'Channels', { exact: true } ) - ).toBeVisible(); - await expect( - page.getByText( 'Discover more marketing tools' ) - ).toBeVisible(); - await expect( - page.getByRole( 'tab', { name: 'Email' } ) - ).toBeVisible(); - await expect( - page.getByRole( 'tab', { name: 'Automations' } ) - ).toBeVisible(); - await expect( - page.getByRole( 'tab', { name: 'Conversion' } ) - ).toBeVisible(); - await expect( - page.getByRole( 'tab', { name: 'CRM', exact: true } ) - ).toBeVisible(); - await expect( - page.getByText( 'Learn about marketing a store' ) - ).toBeVisible(); - } ); + // Sections present + await expect( + page.getByText( 'Channels', { exact: true } ) + ).toBeVisible(); + await expect( + page.getByText( 'Discover more marketing tools' ) + ).toBeVisible(); + await expect( + page.getByRole( 'tab', { name: 'Email' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'tab', { name: 'Automations' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'tab', { name: 'Conversion' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'tab', { name: 'CRM', exact: true } ) + ).toBeVisible(); + await expect( + page.getByText( 'Learn about marketing a store' ) + ).toBeVisible(); + } + ); test( 'Introduction can be dismissed', diff --git a/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js index eec70d721a8..035b63be270 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js @@ -73,7 +73,7 @@ test.describe( 'Payment setup task', () => { await page .locator( '//input[@placeholder="BIC / Swift"]' ) .fill( 'ABBA' ); - await page.locator( 'text=Save' ).click(); + await page.getByRole( 'button', { name: 'Save' } ).click(); // Check that bank transfers were set up. await expect( @@ -93,7 +93,7 @@ test.describe( 'Payment setup task', () => { page, } ) => { await page.goto( 'wp-admin/admin.php?page=wc-admin' ); - await page.locator( 'text=Get paid' ).click(); + await page.getByRole( 'button', { name: '3 Get paid' } ).click(); await expect( page.locator( '.woocommerce-layout__header-wrapper > h1' ) ).toHaveText( 'Get paid' ); From 1ef1aaa1f077738770b0697a4c47ec92c34fbffe Mon Sep 17 00:00:00 2001 From: Seghir Nadir Date: Mon, 16 Sep 2024 15:03:20 +0200 Subject: [PATCH 09/36] Hadren styles for interactive elements in Checkout block (#51375) * reset styles for panel button * reset styles for address card edit and address line 2 * Update shipping selector buttons * fix line height * Add changefile(s) from automation for the following project(s): woocommerce-blocks * remove extra styles no longer needed * update styles to balance chevron and change to span --------- Co-authored-by: github-actions Co-authored-by: Alex Florisca --- .../form/address-line-2-field.tsx | 6 +- .../js/blocks/checkout/address-card/index.tsx | 6 +- .../checkout-shipping-method-block/block.tsx | 6 +- .../checkout-shipping-method-block/edit.tsx | 6 +- .../checkout-shipping-method-block/style.scss | 15 +- plugins/woocommerce-blocks/package.json | 2 +- .../packages/components/panel/index.tsx | 42 +- .../packages/components/panel/style.scss | 31 +- ...date-switch-to-ariakit-buttons-in-checkout | 4 + pnpm-lock.yaml | 360 +++++++++++++++--- 10 files changed, 357 insertions(+), 121 deletions(-) create mode 100644 plugins/woocommerce/changelog/51375-update-switch-to-ariakit-buttons-in-checkout diff --git a/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx b/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx index 0c12494ff70..ad7bb145709 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx +++ b/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx @@ -5,6 +5,7 @@ import { ValidatedTextInput } from '@woocommerce/blocks-components'; import { AddressFormValues, ContactFormValues } from '@woocommerce/settings'; import { useState, Fragment, useCallback } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; +import { Button } from '@ariakit/react'; /** * Internal dependencies @@ -50,7 +51,8 @@ const AddressLine2Field = < T extends AddressFormValues | ContactFormValues >( { /> ) : ( <> - + { onEdit && ( - + ) } ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx index f6b033aa709..a42bdd5a61d 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx @@ -10,6 +10,7 @@ import { CART_STORE_KEY, VALIDATION_STORE_KEY } from '@woocommerce/block-data'; import { useDispatch, useSelect } from '@wordpress/data'; import { isPackageRateCollectable } from '@woocommerce/base-utils'; import { getSetting } from '@woocommerce/settings'; +import { Button } from '@ariakit/react'; /** * Internal dependencies @@ -18,7 +19,6 @@ import { RatePrice, getLocalPickupPrices, getShippingPrices } from './shared'; import type { minMaxPrices } from './shared'; import { defaultLocalPickupText, defaultShippingText } from './constants'; import { shippingAddressHasValidationErrors } from '../../../../data/cart/utils'; -import Button from '../../../../base/components/button'; const SHIPPING_RATE_ERROR = { hidden: true, @@ -44,8 +44,8 @@ const LocalPickupSelector = ( { } ) => { return ( - + { isOpen && (
{ children } diff --git a/plugins/woocommerce-blocks/packages/components/panel/style.scss b/plugins/woocommerce-blocks/packages/components/panel/style.scss index ac4f5722bde..8df33917d6a 100644 --- a/plugins/woocommerce-blocks/packages/components/panel/style.scss +++ b/plugins/woocommerce-blocks/packages/components/panel/style.scss @@ -14,31 +14,28 @@ } .wc-block-components-panel__button { - @include reset-box(); + box-sizing: border-box; height: auto; - line-height: 1; + line-height: inherit; margin-top: em(6px); padding-right: #{24px + $gap-smaller}; padding-top: em($gap-small - 6px); + padding-left: 0 !important; position: relative; text-align: left; width: 100%; word-break: break-word; &[aria-expanded="true"] { - padding-bottom: $gap-smaller; - margin-bottom: $gap-smaller; + margin-bottom: $gap-smaller * 2; } &, &:hover, &:focus, &:active { - @include reset-color(); - @include reset-typography(); - background: transparent; - box-shadow: none; cursor: pointer; + padding-left: 0 !important; } > .wc-block-components-panel__button-icon { @@ -58,21 +55,3 @@ display: none; } } - -// Extra classes for specificity. -.theme-twentytwentyone.theme-twentytwentyone.theme-twentytwentyone -.wc-block-components-panel__button { - background-color: inherit; - color: inherit; -} - -.theme-twentytwenty .wc-block-components-panel__button, -.theme-twentyseventeen .wc-block-components-panel__button { - background: none transparent; - color: inherit; - - &.wc-block-components-panel__button:hover, - &.wc-block-components-panel__button:focus { - background: none transparent; - } -} diff --git a/plugins/woocommerce/changelog/51375-update-switch-to-ariakit-buttons-in-checkout b/plugins/woocommerce/changelog/51375-update-switch-to-ariakit-buttons-in-checkout new file mode 100644 index 00000000000..ccedf5ae319 --- /dev/null +++ b/plugins/woocommerce/changelog/51375-update-switch-to-ariakit-buttons-in-checkout @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Harden styles for interactive elements in Checkout block to prevent style leakage. \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 296a64d656a..a2f8a2a6aab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,7 +256,7 @@ importers: version: 10.5.0(sass@1.69.5)(webpack@5.89.0(webpack-cli@3.3.12)) ts-jest: specifier: ~29.1.1 - version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) + version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) typescript: specifier: ^5.3.3 version: 5.3.3 @@ -292,7 +292,7 @@ importers: version: 2.3.2 debug: specifier: ^4.3.4 - version: 4.3.4(supports-color@9.4.0) + version: 4.3.4(supports-color@8.1.1) dompurify: specifier: ^2.4.7 version: 2.4.7 @@ -453,7 +453,7 @@ importers: version: 27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)) ts-jest: specifier: ~29.1.1 - version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) + version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) typescript: specifier: ^5.3.3 version: 5.3.3 @@ -2866,7 +2866,7 @@ importers: dependencies: debug: specifier: ^4.3.4 - version: 4.3.4(supports-color@9.4.0) + version: 4.3.4(supports-color@8.1.1) devDependencies: '@babel/core': specifier: ^7.23.5 @@ -2975,7 +2975,7 @@ importers: version: 1.2.5(@types/react@17.0.71)(react-dom@17.0.2(react@17.0.2))(react-with-direction@1.4.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(react@17.0.2) debug: specifier: ^4.3.4 - version: 4.3.4(supports-color@9.4.0) + version: 4.3.4(supports-color@8.1.1) prop-types: specifier: ^15.8.1 version: 15.8.1 @@ -2991,10 +2991,10 @@ importers: devDependencies: '@babel/preset-react': specifier: 7.23.3 - version: 7.23.3(@babel/core@7.25.2) + version: 7.23.3(@babel/core@7.24.7) '@babel/preset-typescript': specifier: 7.23.2 - version: 7.23.2(@babel/core@7.25.2) + version: 7.23.2(@babel/core@7.24.7) '@svgr/webpack': specifier: ^8.1.0 version: 8.1.0(typescript@5.3.3) @@ -3045,10 +3045,10 @@ importers: version: 2.17.0(wp-prettier@2.8.5) '@wordpress/scripts': specifier: ^19.2.4 - version: 19.2.4(@babel/core@7.25.2)(debug@4.3.4)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4) + version: 19.2.4(@babel/core@7.24.7)(debug@4.3.4)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4) babel-jest: specifier: ~27.5.1 - version: 27.5.1(@babel/core@7.25.2) + version: 27.5.1(@babel/core@7.24.7) eslint: specifier: ^8.55.0 version: 8.55.0 @@ -3352,7 +3352,7 @@ importers: version: 3.34.0 debug: specifier: ^4.3.4 - version: 4.3.4(supports-color@9.4.0) + version: 4.3.4(supports-color@8.1.1) dompurify: specifier: ^2.4.7 version: 2.4.7 @@ -3906,7 +3906,7 @@ importers: version: 2.17.0(wp-prettier@2.8.5) '@wordpress/scripts': specifier: ^19.2.4 - version: 19.2.4(@babel/core@7.25.2)(debug@4.3.4)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4) + version: 19.2.4(@babel/core@7.25.2)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4) eslint: specifier: ^8.55.0 version: 8.55.0 @@ -3929,7 +3929,7 @@ importers: plugins/woocommerce-blocks: dependencies: '@ariakit/react': - specifier: ^0.4.4 + specifier: ^0.4.5 version: 0.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@dnd-kit/core': specifier: ^6.1.0 @@ -4642,7 +4642,7 @@ importers: version: 27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)) ts-jest: specifier: ~29.1.1 - version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) + version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3) @@ -4839,7 +4839,7 @@ importers: version: 1.2.2 ts-jest: specifier: ~29.1.1 - version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) + version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) ts-loader: specifier: ^9.5.1 version: 9.5.1(typescript@5.3.3)(webpack@5.89.0(webpack-cli@3.3.12)) @@ -21629,7 +21629,7 @@ packages: engines: {node: '>=18'} hasBin: true peerDependencies: - react: ^17.0.2 + react: 18.2.0 react-number-format@4.9.3: resolution: {integrity: sha512-am1A1xYAbENuKJ+zpM7V+B1oRTSeOHYltqVKExznIVFweBzhLmOBmyb1DfIKjHo90E0bo1p3nzVJ2NgS5xh+sQ==} @@ -21819,7 +21819,7 @@ packages: react-with-direction@1.4.0: resolution: {integrity: sha512-ybHNPiAmaJpoWwugwqry9Hd1Irl2hnNXlo/2SXQBwbLn/jGMauMS2y9jw+ydyX5V9ICryCqObNSthNt5R94xpg==} peerDependencies: - react: ^17.0.2 + react: ^0.14 || ^15 || ^16 react-dom: ^0.14 || ^15 || ^16 react-with-styles-interface-css@4.0.3: @@ -25603,7 +25603,7 @@ snapshots: '@wordpress/primitives': 3.55.0 '@wordpress/react-i18n': 3.55.0 classnames: 2.3.2 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) react: 17.0.2 react-dom: 17.0.2(react@17.0.2) react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -25628,7 +25628,7 @@ snapshots: '@wordpress/primitives': 3.55.0 '@wordpress/react-i18n': 3.55.0 classnames: 2.3.2 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) react: 17.0.2 react-dom: 17.0.2(react@17.0.2) react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -25732,7 +25732,7 @@ snapshots: '@babel/traverse': 7.23.5 '@babel/types': 7.23.5 convert-source-map: 1.9.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 lodash: 4.17.21 @@ -25755,7 +25755,7 @@ snapshots: '@babel/traverse': 7.23.5 '@babel/types': 7.23.5 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -25775,7 +25775,7 @@ snapshots: '@babel/traverse': 7.23.5 '@babel/types': 7.23.5 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -25838,6 +25838,14 @@ snapshots: eslint-visitor-keys: 2.1.0 semver: 6.3.1 + '@babel/eslint-parser@7.23.3(@babel/core@7.24.7)(eslint@7.32.0)': + dependencies: + '@babel/core': 7.24.7 + '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 + eslint: 7.32.0 + eslint-visitor-keys: 2.1.0 + semver: 6.3.1 + '@babel/eslint-parser@7.23.3(@babel/core@7.25.2)(eslint@7.32.0)': dependencies: '@babel/core': 7.25.2 @@ -26002,6 +26010,19 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.7 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.23.6(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-function-name': 7.24.7 + '@babel/helper-member-expression-to-functions': 7.23.0 + '@babel/helper-optimise-call-expression': 7.22.5 + '@babel/helper-replace-supers': 7.22.20(@babel/core@7.24.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/helper-split-export-declaration': 7.24.7 + semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.23.6(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -26993,7 +27014,6 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@babel/helper-plugin-utils': 7.24.8 - optional: true '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.25.2)': dependencies: @@ -27230,6 +27250,11 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-jsx@7.22.5(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-syntax-jsx@7.22.5(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -28841,6 +28866,11 @@ snapshots: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-transform-react-display-name@7.24.7(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-transform-react-display-name@7.24.7(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -28860,6 +28890,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-react-jsx-development@7.22.5(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/plugin-transform-react-jsx': 7.25.2(@babel/core@7.24.7) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-development@7.22.5(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -28951,6 +28988,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-module-imports': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.7) + '@babel/types': 7.25.2 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -28974,6 +29022,12 @@ snapshots: '@babel/helper-annotate-as-pure': 7.24.7 '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-transform-react-pure-annotations@7.23.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-transform-react-pure-annotations@7.23.3(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -29286,13 +29340,13 @@ snapshots: '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-typescript': 7.23.3(@babel/core@7.23.2) - '@babel/plugin-transform-typescript@7.23.6(@babel/core@7.25.2)': + '@babel/plugin-transform-typescript@7.23.6(@babel/core@7.24.7)': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.24.7 '@babel/helper-annotate-as-pure': 7.24.7 - '@babel/helper-create-class-features-plugin': 7.23.6(@babel/core@7.25.2) + '@babel/helper-create-class-features-plugin': 7.23.6(@babel/core@7.24.7) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-typescript': 7.23.3(@babel/core@7.25.2) + '@babel/plugin-syntax-typescript': 7.23.3(@babel/core@7.24.7) '@babel/plugin-transform-typescript@7.25.2(@babel/core@7.12.9)': dependencies: @@ -30033,8 +30087,8 @@ snapshots: '@babel/preset-flow@7.23.3(@babel/core@7.23.2)': dependencies: '@babel/core': 7.23.2 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/helper-validator-option': 7.24.8 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-validator-option': 7.23.5 '@babel/plugin-transform-flow-strip-types': 7.23.3(@babel/core@7.23.2) '@babel/preset-flow@7.23.3(@babel/core@7.23.5)': @@ -30119,6 +30173,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/preset-react@7.23.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-validator-option': 7.24.8 + '@babel/plugin-transform-react-display-name': 7.24.7(@babel/core@7.24.7) + '@babel/plugin-transform-react-jsx': 7.25.2(@babel/core@7.24.7) + '@babel/plugin-transform-react-jsx-development': 7.22.5(@babel/core@7.24.7) + '@babel/plugin-transform-react-pure-annotations': 7.23.3(@babel/core@7.24.7) + transitivePeerDependencies: + - supports-color + '@babel/preset-react@7.23.3(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -30142,14 +30208,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.23.2(@babel/core@7.25.2)': + '@babel/preset-typescript@7.23.2(@babel/core@7.24.7)': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.24.7 '@babel/helper-plugin-utils': 7.22.5 '@babel/helper-validator-option': 7.23.5 - '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.25.2) - '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.25.2) - '@babel/plugin-transform-typescript': 7.23.6(@babel/core@7.25.2) + '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.24.7) + '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.24.7) + '@babel/plugin-transform-typescript': 7.23.6(@babel/core@7.24.7) transitivePeerDependencies: - supports-color @@ -32107,7 +32173,7 @@ snapshots: '@oclif/color': 1.0.13 '@oclif/core': 2.15.0(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3) chalk: 4.1.2 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) fs-extra: 9.1.0 http-call: 5.3.0 load-json-file: 5.3.0 @@ -32596,7 +32662,7 @@ snapshots: '@puppeteer/browsers@1.4.6(typescript@5.3.2)': dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.3.0 @@ -32610,7 +32676,7 @@ snapshots: '@puppeteer/browsers@1.4.6(typescript@5.3.3)': dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.3.0 @@ -32624,7 +32690,7 @@ snapshots: '@puppeteer/browsers@1.9.0': dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.3.1 @@ -38349,7 +38415,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.56.0 '@typescript-eslint/type-utils': 5.56.0(eslint@8.55.0)(typescript@5.3.2) '@typescript-eslint/utils': 5.56.0(eslint@8.55.0)(typescript@5.3.2) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.55.0 grapheme-splitter: 1.0.4 ignore: 5.3.0 @@ -38368,7 +38434,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/type-utils': 5.62.0(eslint@8.55.0)(typescript@5.3.2) '@typescript-eslint/utils': 5.62.0(eslint@8.55.0)(typescript@5.3.2) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.55.0 graphemer: 1.4.0 ignore: 5.3.0 @@ -38387,7 +38453,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/type-utils': 5.62.0(eslint@8.55.0)(typescript@5.3.3) '@typescript-eslint/utils': 5.62.0(eslint@8.55.0)(typescript@5.3.3) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.55.0 graphemer: 1.4.0 ignore: 5.3.0 @@ -38467,7 +38533,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.56.0 '@typescript-eslint/types': 5.56.0 '@typescript-eslint/typescript-estree': 5.56.0(typescript@5.3.2) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.55.0 optionalDependencies: typescript: 5.3.2 @@ -39018,7 +39084,7 @@ snapshots: webpack: 5.91.0(@swc/core@1.3.100)(esbuild@0.18.20)(webpack-cli@4.10.0) webpack-cli: 4.10.0(webpack-bundle-analyzer@4.7.0)(webpack-dev-server@4.15.1)(webpack@5.91.0) - '@webpack-cli/configtest@1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0))(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0))': + '@webpack-cli/configtest@1.2.0(webpack-cli@4.10.0(webpack@5.89.0))(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0))': dependencies: webpack: 5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0) webpack-cli: 4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0) @@ -39038,7 +39104,7 @@ snapshots: envinfo: 7.13.0 webpack-cli: 4.10.0(webpack-bundle-analyzer@4.7.0)(webpack-dev-server@4.15.1)(webpack@5.91.0) - '@webpack-cli/info@1.5.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0))': + '@webpack-cli/info@1.5.0(webpack-cli@4.10.0(webpack@5.89.0))': dependencies: envinfo: 7.13.0 webpack-cli: 4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0) @@ -42113,6 +42179,33 @@ snapshots: - supports-color - typescript + '@wordpress/eslint-plugin@9.3.0(@babel/core@7.24.7)(eslint@7.32.0)(typescript@5.3.3)': + dependencies: + '@babel/eslint-parser': 7.23.3(@babel/core@7.24.7)(eslint@7.32.0) + '@typescript-eslint/eslint-plugin': 4.33.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.3.3))(eslint@7.32.0)(typescript@5.3.3) + '@typescript-eslint/parser': 4.33.0(eslint@7.32.0)(typescript@5.3.3) + '@wordpress/prettier-config': 1.4.0(wp-prettier@2.2.1-beta-1) + cosmiconfig: 7.1.0 + eslint: 7.32.0 + eslint-config-prettier: 7.2.0(eslint@7.32.0) + eslint-plugin-import: 2.29.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.3.3))(eslint@7.32.0) + eslint-plugin-jest: 24.7.0(@typescript-eslint/eslint-plugin@4.33.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.3.3))(eslint@7.32.0)(typescript@5.3.3))(eslint@7.32.0)(typescript@5.3.3) + eslint-plugin-jsdoc: 36.1.1(eslint@7.32.0) + eslint-plugin-jsx-a11y: 6.8.0(eslint@7.32.0) + eslint-plugin-prettier: 3.4.1(eslint-config-prettier@7.2.0(eslint@7.32.0))(eslint@7.32.0)(wp-prettier@2.2.1-beta-1) + eslint-plugin-react: 7.33.2(eslint@7.32.0) + eslint-plugin-react-hooks: 4.6.0(eslint@7.32.0) + globals: 12.4.0 + prettier: wp-prettier@2.2.1-beta-1 + requireindex: 1.2.0 + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - '@babel/core' + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + '@wordpress/eslint-plugin@9.3.0(@babel/core@7.25.2)(eslint@7.32.0)(typescript@5.3.3)': dependencies: '@babel/eslint-parser': 7.23.3(@babel/core@7.25.2)(eslint@7.32.0) @@ -42467,6 +42560,20 @@ snapshots: - react-dom - supports-color + '@wordpress/jest-preset-default@7.1.3(@babel/core@7.24.7)(jest@26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)': + dependencies: + '@wojtekmaj/enzyme-adapter-react-17': 0.6.7(enzyme@3.11.0)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@wordpress/jest-console': 4.1.1(jest@26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))) + babel-jest: 26.6.3(@babel/core@7.24.7) + enzyme: 3.11.0 + enzyme-to-json: 3.6.2(enzyme@3.11.0) + jest: 26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)) + transitivePeerDependencies: + - '@babel/core' + - react + - react-dom + - supports-color + '@wordpress/jest-preset-default@7.1.3(@babel/core@7.25.2)(jest@26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)': dependencies: '@wojtekmaj/enzyme-adapter-react-17': 0.6.7(enzyme@3.11.0)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -43216,7 +43323,86 @@ snapshots: - utf-8-validate - webpack-command - '@wordpress/scripts@19.2.4(@babel/core@7.25.2)(debug@4.3.4)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4)': + '@wordpress/scripts@19.2.4(@babel/core@7.24.7)(debug@4.3.4)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4)': + dependencies: + '@svgr/webpack': 5.5.0 + '@wordpress/babel-preset-default': 6.17.0 + '@wordpress/browserslist-config': 4.1.3 + '@wordpress/dependency-extraction-webpack-plugin': 3.7.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + '@wordpress/eslint-plugin': 9.3.0(@babel/core@7.24.7)(eslint@7.32.0)(typescript@5.3.3) + '@wordpress/jest-preset-default': 7.1.3(@babel/core@7.24.7)(jest@26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@wordpress/npm-package-json-lint-config': 4.32.0(npm-package-json-lint@5.4.2) + '@wordpress/postcss-plugins-preset': 3.6.1(postcss@8.4.32) + '@wordpress/prettier-config': 1.4.0(wp-prettier@2.2.1-beta-1) + '@wordpress/stylelint-config': 19.1.0(stylelint@13.13.1) + babel-jest: 26.6.3(@babel/core@7.24.7) + babel-loader: 8.3.0(@babel/core@7.24.7)(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + browserslist: 4.19.3 + chalk: 4.1.2 + check-node-version: 4.2.1 + clean-webpack-plugin: 3.0.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + cross-spawn: 5.1.0 + css-loader: 6.8.1(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + cssnano: 5.1.12(postcss@8.4.32) + cwd: 0.10.0 + dir-glob: 3.0.1 + eslint: 7.32.0 + eslint-plugin-markdown: 2.2.1(eslint@7.32.0) + expect-puppeteer: 4.4.0 + filenamify: 4.3.0 + jest: 26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)) + jest-circus: 26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)) + jest-dev-server: 5.0.3(debug@4.3.4) + jest-environment-node: 26.6.2 + markdownlint: 0.23.1 + markdownlint-cli: 0.27.1 + merge-deep: 3.0.3 + mini-css-extract-plugin: 2.7.6(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + minimist: 1.2.8 + npm-package-json-lint: 5.4.2 + postcss: 8.4.32 + postcss-loader: 6.2.1(postcss@8.4.32)(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + prettier: wp-prettier@2.2.1-beta-1 + puppeteer-core: 10.4.0 + read-pkg-up: 1.0.1 + resolve-bin: 0.4.3 + sass: 1.69.5 + sass-loader: 12.6.0(sass@1.69.5)(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + source-map-loader: 3.0.2(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + stylelint: 13.13.1 + terser-webpack-plugin: 5.3.6(uglify-js@3.17.4)(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + webpack: 5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0) + webpack-bundle-analyzer: 4.7.0 + webpack-cli: 4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0) + webpack-livereload-plugin: 3.0.2(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + transitivePeerDependencies: + - '@babel/core' + - '@swc/core' + - '@webpack-cli/generators' + - '@webpack-cli/migrate' + - bufferutil + - canvas + - debug + - esbuild + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - fibers + - file-loader + - node-sass + - postcss-jsx + - postcss-markdown + - react + - react-dom + - sass-embedded + - supports-color + - ts-node + - typescript + - uglify-js + - utf-8-validate + - webpack-dev-server + + '@wordpress/scripts@19.2.4(@babel/core@7.25.2)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4)': dependencies: '@svgr/webpack': 5.5.0 '@wordpress/babel-preset-default': 6.17.0 @@ -44568,6 +44754,20 @@ snapshots: transitivePeerDependencies: - supports-color + babel-jest@26.6.3(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 + '@jest/transform': 26.6.2 + '@jest/types': 26.6.2 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 26.6.2(@babel/core@7.24.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + babel-jest@26.6.3(@babel/core@7.25.2): dependencies: '@babel/core': 7.25.2 @@ -44596,6 +44796,20 @@ snapshots: transitivePeerDependencies: - supports-color + babel-jest@27.5.1(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 + '@jest/transform': 27.5.1 + '@jest/types': 27.5.1 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 27.5.1(@babel/core@7.24.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + babel-jest@27.5.1(@babel/core@7.25.2): dependencies: '@babel/core': 7.25.2 @@ -44700,6 +44914,15 @@ snapshots: schema-utils: 2.7.1 webpack: 5.91.0(@swc/core@1.3.100)(esbuild@0.18.20)(webpack-cli@4.10.0) + babel-loader@8.3.0(@babel/core@7.24.7)(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)): + dependencies: + '@babel/core': 7.24.7 + find-cache-dir: 3.3.2 + loader-utils: 2.0.4 + make-dir: 3.1.0 + schema-utils: 2.7.1 + webpack: 5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0) + babel-loader@8.3.0(@babel/core@7.25.2)(webpack@4.47.0(webpack-cli@3.3.12(webpack@5.89.0))): dependencies: '@babel/core': 7.25.2 @@ -45143,7 +45366,6 @@ snapshots: '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.7) '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.7) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.7) - optional: true babel-preset-current-node-syntax@1.0.1(@babel/core@7.25.2): dependencies: @@ -45191,6 +45413,12 @@ snapshots: babel-plugin-jest-hoist: 26.6.2 babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.5) + babel-preset-jest@26.6.2(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 + babel-plugin-jest-hoist: 26.6.2 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.7) + babel-preset-jest@26.6.2(@babel/core@7.25.2): dependencies: '@babel/core': 7.25.2 @@ -45203,6 +45431,12 @@ snapshots: babel-plugin-jest-hoist: 27.5.1 babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.5) + babel-preset-jest@27.5.1(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 + babel-plugin-jest-hoist: 27.5.1 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.7) + babel-preset-jest@27.5.1(@babel/core@7.25.2): dependencies: '@babel/core': 7.25.2 @@ -48173,7 +48407,7 @@ snapshots: eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.56.0(eslint@8.55.0)(typescript@5.3.2))(eslint-import-resolver-webpack@0.13.2)(eslint-plugin-import@2.28.1)(eslint@8.55.0): dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) enhanced-resolve: 5.15.0 eslint: 8.55.0 eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.56.0(eslint@8.55.0)(typescript@5.3.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.56.0(eslint@8.55.0)(typescript@5.3.2))(eslint-import-resolver-webpack@0.13.2)(eslint-plugin-import@2.28.1)(eslint@8.55.0))(eslint-import-resolver-webpack@0.13.2(eslint-plugin-import@2.28.1)(webpack@5.91.0(@swc/core@1.3.100)(esbuild@0.18.20)(webpack-cli@5.1.4)))(eslint@8.55.0) @@ -48190,7 +48424,7 @@ snapshots: eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.55.0)(typescript@5.3.3))(eslint-import-resolver-webpack@0.13.8)(eslint-plugin-import@2.29.0)(eslint@8.55.0): dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) enhanced-resolve: 5.15.0 eslint: 8.55.0 eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.62.0(eslint@8.55.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.55.0)(typescript@5.3.3))(eslint-import-resolver-webpack@0.13.8)(eslint-plugin-import@2.29.0)(eslint@8.55.0))(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.0)(webpack@5.89.0(webpack-cli@4.10.0)))(eslint@8.55.0) @@ -49598,7 +49832,7 @@ snapshots: follow-redirects@1.15.6(debug@4.3.4): optionalDependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) follow-redirects@1.5.10: dependencies: @@ -52791,7 +53025,9 @@ snapshots: pretty-format: 24.9.0 throat: 4.1.0 transitivePeerDependencies: + - bufferutil - supports-color + - utf-8-validate jest-jasmine2@25.5.4: dependencies: @@ -54463,7 +54699,7 @@ snapshots: chalk: 5.2.0 cli-truncate: 3.1.0 commander: 10.0.1 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) execa: 7.2.0 lilconfig: 2.1.0 listr2: 5.0.8(enquirer@2.4.1) @@ -55760,7 +55996,7 @@ snapshots: dependencies: carlo: 0.9.46 chokidar: 3.5.3 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) isbinaryfile: 3.0.3 mime: 2.6.0 opn: 5.5.0 @@ -56288,7 +56524,7 @@ snapshots: '@oclif/plugin-warn-if-update-available': 2.1.1(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3) aws-sdk: 2.1515.0 concurrently: 7.6.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) find-yarn-workspace-root: 2.0.0 fs-extra: 8.1.0 github-slugger: 1.5.0 @@ -57798,7 +58034,7 @@ snapshots: puppeteer-core@13.7.0(encoding@0.1.13): dependencies: cross-fetch: 3.1.5(encoding@0.1.13) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) devtools-protocol: 0.0.981744 extract-zip: 2.0.1 https-proxy-agent: 5.0.1 @@ -57837,7 +58073,7 @@ snapshots: '@puppeteer/browsers': 1.4.6(typescript@5.3.2) chromium-bidi: 0.4.16(devtools-protocol@0.0.1147663) cross-fetch: 4.0.0(encoding@0.1.13) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) devtools-protocol: 0.0.1147663 ws: 8.13.0 optionalDependencies: @@ -57853,7 +58089,7 @@ snapshots: '@puppeteer/browsers': 1.4.6(typescript@5.3.3) chromium-bidi: 0.4.16(devtools-protocol@0.0.1147663) cross-fetch: 4.0.0(encoding@0.1.13) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) devtools-protocol: 0.0.1147663 ws: 8.13.0 optionalDependencies: @@ -57869,7 +58105,7 @@ snapshots: '@puppeteer/browsers': 1.9.0 chromium-bidi: 0.5.1(devtools-protocol@0.0.1203626) cross-fetch: 4.0.0(encoding@0.1.13) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) devtools-protocol: 0.0.1203626 ws: 8.14.2 transitivePeerDependencies: @@ -57905,7 +58141,7 @@ snapshots: puppeteer@17.1.3(encoding@0.1.13): dependencies: cross-fetch: 3.1.5(encoding@0.1.13) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) devtools-protocol: 0.0.1036444 extract-zip: 2.0.1 https-proxy-agent: 5.0.1 @@ -58233,7 +58469,7 @@ snapshots: react-docgen-typescript-plugin@1.0.5(typescript@5.3.2)(webpack@5.91.0(@swc/core@1.3.100)(esbuild@0.18.20)(webpack-cli@5.1.4)): dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) endent: 2.1.0 find-cache-dir: 3.3.2 flat-cache: 3.2.0 @@ -60110,7 +60346,7 @@ snapshots: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -60859,7 +61095,7 @@ snapshots: colord: 2.9.3 cosmiconfig: 7.1.0 css-functions-list: 3.2.1 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) fast-glob: 3.3.2 fastest-levenshtein: 1.0.16 file-entry-cache: 6.0.1 @@ -61663,7 +61899,7 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.24.7) - ts-jest@29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3): + ts-jest@29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -62769,8 +63005,8 @@ snapshots: webpack-cli@4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0))(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) - '@webpack-cli/info': 1.5.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0)) + '@webpack-cli/configtest': 1.2.0(webpack-cli@4.10.0(webpack@5.89.0))(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + '@webpack-cli/info': 1.5.0(webpack-cli@4.10.0(webpack@5.89.0)) '@webpack-cli/serve': 1.7.0(webpack-cli@4.10.0(webpack@5.89.0)) colorette: 2.0.20 commander: 7.2.0 From a89bd1a48510b617b166cb6ce555bdd3eda95084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomek=20Wytr=C4=99bowicz?= Date: Mon, 16 Sep 2024 15:57:58 +0200 Subject: [PATCH 10/36] Use page query param consistently as string for ReportsTable (#51396) --- packages/js/data/changelog/fix-51395-reporttable-page | 4 ++++ packages/js/data/src/reports/utils.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 packages/js/data/changelog/fix-51395-reporttable-page diff --git a/packages/js/data/changelog/fix-51395-reporttable-page b/packages/js/data/changelog/fix-51395-reporttable-page new file mode 100644 index 00000000000..9b02145b01f --- /dev/null +++ b/packages/js/data/changelog/fix-51395-reporttable-page @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Use page query param consistently as string for `getReportTableQuery`. diff --git a/packages/js/data/src/reports/utils.ts b/packages/js/data/src/reports/utils.ts index 065c4847653..39cf4869798 100644 --- a/packages/js/data/src/reports/utils.ts +++ b/packages/js/data/src/reports/utils.ts @@ -561,7 +561,7 @@ export function getReportTableQuery( before: noIntervals ? undefined : appendTimestamp( datesFromQuery.primary.before, 'end' ), - page: query.paged || 1, + page: query.paged || '1', per_page: query.per_page || QUERY_DEFAULTS.pageSize, ...filterQuery, ...tableQuery, From 450a299bae4ae4891db39e2e2c0646d1ac1e9586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomek=20Wytr=C4=99bowicz?= Date: Mon, 16 Sep 2024 15:59:18 +0200 Subject: [PATCH 11/36] Fix tax_code in the report export for removed rates (#51395) --- .../changelog/fix-49759-removed-taxname-export | 4 ++++ .../src/Admin/API/Reports/Taxes/Controller.php | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 plugins/woocommerce/changelog/fix-49759-removed-taxname-export diff --git a/plugins/woocommerce/changelog/fix-49759-removed-taxname-export b/plugins/woocommerce/changelog/fix-49759-removed-taxname-export new file mode 100644 index 00000000000..2d76f39f150 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-49759-removed-taxname-export @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Improve performance of tax report export generation and fix tax_code for removed rates. diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php index d31db463c0c..a794fee018f 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php @@ -244,7 +244,15 @@ class Controller extends GenericController implements ExportableInterface { */ public function prepare_item_for_export( $item ) { return array( - 'tax_code' => \WC_Tax::get_rate_code( $item['tax_rate_id'] ), + 'tax_code' => \WC_Tax::get_rate_code( + (object) array( + 'tax_rate_id' => $item['tax_rate_id'], + 'tax_rate_country' => $item['country'], + 'tax_rate_state' => $item['state'], + 'tax_rate_name' => $item['name'], + 'tax_rate_priority' => $item['priority'], + ) + ), 'rate' => $item['tax_rate'], 'total_tax' => self::csv_number_format( $item['total_tax'] ), 'order_tax' => self::csv_number_format( $item['order_tax'] ), From bf0646c307e7adfa8c040135a9bc664a5ea99dad Mon Sep 17 00:00:00 2001 From: Boro Sitnikovski Date: Mon, 16 Sep 2024 16:57:05 +0200 Subject: [PATCH 12/36] Add error handling when the call to 'install-url' endpoint fails (#51298) * Add error handling when the call to 'install-url' endpoint fails * Changelog * Ignore any explicitly * Move the function below to address lint * Change 'Try again' to a href, suggesting to download and install manually * Lint --- .../table/actions/install.tsx | 115 +++++++++--------- ...fix-wccom-20223-in-app-subs-error-handling | 4 + 2 files changed, 60 insertions(+), 59 deletions(-) create mode 100644 plugins/woocommerce/changelog/fix-wccom-20223-in-app-subs-error-handling diff --git a/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx b/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx index 1691f46c4e3..6b321d07bd9 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx @@ -51,6 +51,48 @@ export default function Install( props: InstallProps ) { ); }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleInstallError = ( error: any ) => { + loadSubscriptions( false ).then( () => { + let errorMessage = sprintf( + // translators: %s is the product name. + __( '%s couldn’t be installed.', 'woocommerce' ), + props.subscription.product_name + ); + if ( error?.success === false && error?.data.message ) { + errorMessage += ' ' + error.data.message; + } + addNotice( + props.subscription.product_key, + errorMessage, + NoticeStatus.Error, + { + actions: [ + { + label: __( + 'Download and install manually', + 'woocommerce' + ), + url: 'https://woocommerce.com/my-account/downloads/', + }, + ], + } + ); + stopInstall(); + + if ( props.onError ) { + props.onError(); + } + } ); + + recordEvent( 'marketplace_product_install_failed', { + product_zip_slug: props.subscription.zip_slug, + product_id: props.subscription.product_id, + product_current_version: props.subscription.version, + error_message: error?.data?.message, + } ); + }; + const install = () => { recordEvent( 'marketplace_product_install_button_clicked', { product_zip_slug: props.subscription.zip_slug, @@ -90,71 +132,26 @@ export default function Install( props: InstallProps ) { props.onSuccess(); } } ) - .catch( ( error ) => { - loadSubscriptions( false ).then( () => { - let errorMessage = sprintf( - // translators: %s is the product name. - __( '%s couldn’t be installed.', 'woocommerce' ), - props.subscription.product_name - ); - if ( error?.success === false && error?.data.message ) { - errorMessage += ' ' + error.data.message; - } - addNotice( - props.subscription.product_key, - errorMessage, - NoticeStatus.Error, - { - actions: [ - { - label: __( 'Try again', 'woocommerce' ), - onClick: install, - }, - ], - } - ); - stopInstall(); - - if ( props.onError ) { - props.onError(); - } - } ); - - recordEvent( 'marketplace_product_install_failed', { + .catch( handleInstallError ); + } else { + getInstallUrl( props.subscription ) + .then( ( url: string ) => { + recordEvent( 'marketplace_product_install_url', { product_zip_slug: props.subscription.zip_slug, product_id: props.subscription.product_id, product_current_version: props.subscription.version, - error_message: error?.data?.message, + product_install_url: url, } ); - } ); - } else { - getInstallUrl( props.subscription ).then( ( url: string ) => { - recordEvent( 'marketplace_product_install_url', { - product_zip_slug: props.subscription.zip_slug, - product_id: props.subscription.product_id, - product_current_version: props.subscription.version, - product_install_url: url, - } ); - stopInstall(); + stopInstall(); - if ( url ) { - window.open( url, '_self' ); - } else { - addNotice( - props.subscription.product_key, - sprintf( - // translators: %s is the product name. - __( - '%s couldn’t be installed. Please install the product manually.', - 'woocommerce' - ), - props.subscription.product_name - ), - NoticeStatus.Error - ); - } - } ); + if ( url ) { + window.open( url, '_self' ); + } else { + throw new Error(); + } + } ) + .catch( handleInstallError ); } }; diff --git a/plugins/woocommerce/changelog/fix-wccom-20223-in-app-subs-error-handling b/plugins/woocommerce/changelog/fix-wccom-20223-in-app-subs-error-handling new file mode 100644 index 00000000000..0815a090e50 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-wccom-20223-in-app-subs-error-handling @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Introduce error handling on the in-app my subscriptions page From e4d995938fe18ef9af3cf28bcfc1901338de5a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20P=C3=A9rez=20Pellicer?= <5908855+puntope@users.noreply.github.com> Date: Mon, 16 Sep 2024 22:45:47 +0400 Subject: [PATCH 13/36] Set customer email in reports data if available (#51367) * Set customer email in reports data if available. * Add changelog * Lint --- .../changelog/fix-use-customer-email-if-available | 4 ++++ .../src/Admin/API/Reports/Customers/DataStore.php | 5 +++++ 2 files changed, 9 insertions(+) create mode 100644 plugins/woocommerce/changelog/fix-use-customer-email-if-available diff --git a/plugins/woocommerce/changelog/fix-use-customer-email-if-available b/plugins/woocommerce/changelog/fix-use-customer-email-if-available new file mode 100644 index 00000000000..58490385da9 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-use-customer-email-if-available @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Set customer email in reports if customer data is available diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php index e13ed41c47c..7f740eff74e 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php @@ -615,6 +615,11 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { if ( is_null( $customer_user ) ) { $customer_user = new \WC_Customer( $user_id ); } + + // Set email as customer email instead of Order Billing Email if we have a customer. + $data['email'] = $customer_user->get_email( 'edit' ); + + // Adding other relevant customer data. $data['user_id'] = $user_id; $data['username'] = $customer_user->get_username( 'edit' ); $data['date_registered'] = $customer_user->get_date_created( 'edit' ) ? $customer_user->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ) : null; From 0036b4d293a9ad4e19b47f553c925ff1cd996abf Mon Sep 17 00:00:00 2001 From: RJ <27843274+rjchow@users.noreply.github.com> Date: Tue, 17 Sep 2024 11:33:41 +1000 Subject: [PATCH 14/36] add: tax task completion filter (#51362) * add: tax task completion filter * fix: add phpcs ignore for missing hook comment * lint --- .../woocommerce/changelog/add-tax-task-completion-filter | 4 ++++ .../src/Admin/Features/OnboardingTasks/Tasks/Tax.php | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 plugins/woocommerce/changelog/add-tax-task-completion-filter diff --git a/plugins/woocommerce/changelog/add-tax-task-completion-filter b/plugins/woocommerce/changelog/add-tax-task-completion-filter new file mode 100644 index 00000000000..89ab40a7240 --- /dev/null +++ b/plugins/woocommerce/changelog/add-tax-task-completion-filter @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Adds a filter for third party tax plugins to indicate that they have completed the tax task diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php index ba33d9b2362..b0a65b969e6 100644 --- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php +++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php @@ -15,6 +15,7 @@ class Tax extends Task { /** * Used to cache is_complete() method result. + * * @var null */ private $is_complete_result = null; @@ -109,12 +110,16 @@ class Tax extends Task { */ public function is_complete() { if ( $this->is_complete_result === null ) { - $wc_connect_taxes_enabled = get_option( 'wc_connect_taxes_enabled' ); + $wc_connect_taxes_enabled = get_option( 'wc_connect_taxes_enabled' ); $is_wc_connect_taxes_enabled = ( $wc_connect_taxes_enabled === 'yes' ) || ( $wc_connect_taxes_enabled === true ); // seems that in some places boolean is used, and other places 'yes' | 'no' is used + // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- We will replace this with a formal system by WC 9.6 so lets not advertise it yet. + $third_party_complete = apply_filters( 'woocommerce_admin_third_party_tax_setup_complete', false ); + $this->is_complete_result = $is_wc_connect_taxes_enabled || count( TaxDataStore::get_taxes( array() ) ) > 0 || - get_option( 'woocommerce_no_sales_tax' ) !== false; + get_option( 'woocommerce_no_sales_tax' ) !== false || + $third_party_complete; } return $this->is_complete_result; From 160b3e3ca710fc8487ed603d6fc8fc6f9516bf62 Mon Sep 17 00:00:00 2001 From: Moon Date: Mon, 16 Sep 2024 21:02:21 -0700 Subject: [PATCH 15/36] Add use-wp-horizon feature flag (#51421) * Add use-wp-horizon feature flag * Add changefile(s) from automation for the following project(s): woocommerce --------- Co-authored-by: github-actions --- plugins/woocommerce/changelog/51421-update-use-horizon-env | 4 ++++ plugins/woocommerce/client/admin/config/core.json | 3 ++- plugins/woocommerce/client/admin/config/development.json | 3 ++- plugins/woocommerce/src/Admin/API/OnboardingPlugins.php | 5 +++++ 4 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 plugins/woocommerce/changelog/51421-update-use-horizon-env diff --git a/plugins/woocommerce/changelog/51421-update-use-horizon-env b/plugins/woocommerce/changelog/51421-update-use-horizon-env new file mode 100644 index 00000000000..0258515d62b --- /dev/null +++ b/plugins/woocommerce/changelog/51421-update-use-horizon-env @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add use-wp-horizon feature flag to set calpyso_env to horizon \ No newline at end of file diff --git a/plugins/woocommerce/client/admin/config/core.json b/plugins/woocommerce/client/admin/config/core.json index 3c201f07922..6bfd9954259 100644 --- a/plugins/woocommerce/client/admin/config/core.json +++ b/plugins/woocommerce/client/admin/config/core.json @@ -39,6 +39,7 @@ "launch-your-store": true, "product-editor-template-system": false, "blueprint": false, - "reactify-classic-payments-settings": false + "reactify-classic-payments-settings": false, + "use-wp-horizon": false } } diff --git a/plugins/woocommerce/client/admin/config/development.json b/plugins/woocommerce/client/admin/config/development.json index 802c62fe6a3..da6f07d82dd 100644 --- a/plugins/woocommerce/client/admin/config/development.json +++ b/plugins/woocommerce/client/admin/config/development.json @@ -39,6 +39,7 @@ "launch-your-store": true, "product-editor-template-system": false, "blueprint": true, - "reactify-classic-payments-settings": false + "reactify-classic-payments-settings": false, + "use-wp-horizon": false } } diff --git a/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php b/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php index fb6fa25570d..55f55edfe85 100644 --- a/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php +++ b/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php @@ -11,6 +11,7 @@ defined( 'ABSPATH' ) || exit; use ActionScheduler; use Automattic\Jetpack\Connection\Manager; +use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\PluginsHelper; use Automattic\WooCommerce\Admin\PluginsInstallLoggers\AsynPluginsInstallLogger; use WC_REST_Data_Controller; @@ -238,6 +239,10 @@ class OnboardingPlugins extends WC_REST_Data_Controller { $redirect_url = $request->get_param( 'redirect_url' ); $calypso_env = defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, [ 'development', 'wpcalypso', 'horizon', 'stage' ], true ) ? WOOCOMMERCE_CALYPSO_ENVIRONMENT : 'production'; + if ( Features::is_enabled( 'use-wp-horizon' ) ) { + $calypso_env = 'horizon'; + } + return [ 'success' => ! $errors->has_errors(), 'errors' => $errors->get_error_messages(), From 4ee2689f4ff4eba31bc0cc12be480108141e0896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Juh=C3=A9=20Lluveras?= Date: Tue, 17 Sep 2024 09:49:53 +0200 Subject: [PATCH 16/36] Fix fatal when adding the Product Gallery (Beta) block into a pattern (#51189) * Fix fatal when adding the Product Gallery (Beta) block into a pattern * Add changelog file * Linting --- .../assets/js/blocks/product-gallery/edit.tsx | 22 +++++++++++++++++-- .../js/blocks/product-gallery/utils.tsx | 4 ++-- .../changelog/fix-51154-product-gallery-fatal | 4 ++++ .../src/Blocks/BlockTypes/ProductGallery.php | 11 +++++----- .../src/Blocks/Utils/ProductGalleryUtils.php | 2 +- 5 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 plugins/woocommerce/changelog/fix-51154-product-gallery-fatal diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/edit.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/edit.tsx index f6e3898701f..d220d0fdb67 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/edit.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/edit.tsx @@ -10,6 +10,10 @@ import { import { BlockEditProps, InnerBlockTemplate } from '@wordpress/blocks'; import { useEffect } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; +import ErrorPlaceholder, { + ErrorObject, +} from '@woocommerce/editor-components/error-placeholder'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies @@ -132,14 +136,16 @@ export const Edit = ( { useEffect( () => { const mode = getMode( currentTemplateId, templateType ); + const newProductGalleryClientId = + attributes.productGalleryClientId || clientId; setAttributes( { ...attributes, mode, - productGalleryClientId: clientId, + productGalleryClientId: newProductGalleryClientId, } ); // Move the Thumbnails block to the correct above or below the Large Image based on the thumbnailsPosition attribute. - moveInnerBlocksToPosition( attributes, clientId ); + moveInnerBlocksToPosition( attributes, newProductGalleryClientId ); }, [ setAttributes, attributes, @@ -148,6 +154,18 @@ export const Edit = ( { templateType, ] ); + if ( attributes.productGalleryClientId !== clientId ) { + const error = { + message: __( + 'productGalleryClientId and clientId codes mismatch.', + 'woocommerce' + ), + type: 'general', + } as ErrorObject; + + return ; + } + return (
diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/utils.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/utils.tsx index eb2e15258b2..d5b97d3ea2e 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/utils.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/utils.tsx @@ -136,10 +136,10 @@ export const moveInnerBlocksToPosition = ( ): void => { const { getBlock, getBlockRootClientId, getBlockIndex } = select( 'core/block-editor' ); - const { moveBlockToPosition } = dispatch( 'core/block-editor' ); const productGalleryBlock = getBlock( clientId ); - if ( productGalleryBlock ) { + if ( productGalleryBlock?.name === 'woocommerce/product-gallery' ) { + const { moveBlockToPosition } = dispatch( 'core/block-editor' ); const previousLayout = productGalleryBlock.innerBlocks.length ? productGalleryBlock.innerBlocks[ 0 ].attributes.layout : null; diff --git a/plugins/woocommerce/changelog/fix-51154-product-gallery-fatal b/plugins/woocommerce/changelog/fix-51154-product-gallery-fatal new file mode 100644 index 00000000000..3ed4766c3d0 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-51154-product-gallery-fatal @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix error when adding the Product Gallery (Beta) block into a pattern diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php index 812a3baaf98..9d52abacc8a 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php @@ -110,11 +110,14 @@ class ProductGallery extends AbstractBlock { * @return string Rendered block type output. */ protected function render( $attributes, $content, $block ) { - $post_id = $block->context['postId'] ?? ''; + $post_id = $block->context['postId'] ?? ''; + $product = wc_get_product( $post_id ); + if ( ! $product instanceof \WC_Product ) { + return ''; + } + $product_gallery_images = ProductGalleryUtils::get_product_gallery_images( $post_id, 'thumbnail', array() ); $classname_single_image = ''; - // This is a temporary solution. We have to refactor this code when the block will have to be addable on every page/post https://github.com/woocommerce/woocommerce-blocks/issues/10882. - global $product; if ( count( $product_gallery_images ) < 2 ) { // The gallery consists of a single image. @@ -124,8 +127,6 @@ class ProductGallery extends AbstractBlock { $number_of_thumbnails = $block->attributes['thumbnailsNumberOfThumbnails'] ?? 0; $classname = $attributes['className'] ?? ''; $dialog = isset( $attributes['mode'] ) && 'full' !== $attributes['mode'] ? $this->render_dialog() : ''; - $post_id = $block->context['postId'] ?? ''; - $product = wc_get_product( $post_id ); $product_gallery_first_image = ProductGalleryUtils::get_product_gallery_image_ids( $product, 1 ); $product_gallery_first_image_id = reset( $product_gallery_first_image ); $product_id = strval( $product->get_id() ); diff --git a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php index 78c6cefe9e0..bb8eee65ae9 100644 --- a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php +++ b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php @@ -26,7 +26,7 @@ class ProductGalleryUtils { $product_gallery_images = array(); $product = wc_get_product( $post_id ); - if ( $product ) { + if ( $product instanceof \WC_Product ) { $all_product_gallery_image_ids = self::get_product_gallery_image_ids( $product ); if ( 'full' === $size || 'full' !== $size && count( $all_product_gallery_image_ids ) > 1 ) { From 861bc091d48fcfdb3aa81eee518622f458167dd0 Mon Sep 17 00:00:00 2001 From: Manish Menaria Date: Tue, 17 Sep 2024 14:08:24 +0530 Subject: [PATCH 17/36] Product Collection - Add Editor UI for missing product reference (#51114) * Initial implementation of the missing product state - Changed `getProductCollectionUIStateInEditor` to a hook `useProductCollectionUIState`. - As we added logic to check if selected product reference is deleted which require making an API call. Therefore, I decided to use a hook. - While making an API call to check if product reference is deleted, I decided to show spinner in the block. - Introduced new UI state `DELETED_PRODUCT_REFERENCE` to handle the missing product state. - Updated existing `ProductPicker` component to handle the new UI state. - It's better to use existing component for the missing product state, as it already has all the required UI. * Add changefile(s) from automation for the following project(s): woocommerce-blocks * Use getEntityRecord to check if product exists and other improvements * Remove console log * Add E2E tests for deleted product reference in Product Collection block This commit introduces new E2E tests to verify the behavior of the Product Collection block when dealing with deleted product references. The changes include: 1. New test suite in register-product-collection-tester.block_theme.spec.ts 2. Modification to product-collection.page.ts to support custom product selection 3. Minor update to utils.tsx to handle trashed products These tests ensure that the Product Collection block correctly handles scenarios where referenced products are deleted, trashed, or restored, improving the overall reliability of the feature. * Simplify product creation in Product Collection block test * Refactor E2E test for delete product reference picker 1. Removing the unnecessary `test.describe` block for "Deleted product reference" 2. Eliminating the `beforeEach` hook that was creating a test product 3. Integrating the test product creation directly into the main test This change simplifies the test structure and improves readability while maintaining the same test coverage for the Product Collection block's behavior when dealing with deleted or unavailable products. * Simplify logic for product collection UI state This commit simplifies the handling of the `usesReference` prop in the Product Collection block: 1. In `edit/index.tsx`, directly pass `usesReference` to `useProductCollectionUIState` hook without conditional spreading. 2. In `utils.tsx`, update the type definition of `usesReference` in the `useProductCollectionUIState` hook to explicitly include `undefined`. * Refactor Product Collection block to improve prop passing - Introduce ProductCollectionContentProps type for better prop management - Refactor Edit component to use a renderComponent function - Update component prop types to use more specific props - Remove unnecessary props from ProductCollectionEditComponentProps - Simplify component rendering logic in Edit component - Adjust ProductPicker to receive only required props - Update imports and prop types in various files to use new types This refactoring improves code organization and reduces prop drilling by only passing necessary props to each component. It enhances maintainability and readability of the Product Collection block and related components. * Refactor Product Collection block editor UI state handling This commit simplifies the rendering logic in the Product Collection block's Edit component. It removes a redundant case for the VALID state, as both VALID and VALID_WITH_PREVIEW states now render the same ProductCollectionContent component. This change improves code maintainability and reduces duplication. * Fix: isDeletedProductReference is not set correctly * Use "page.reload" instead of "admin.page.reload" * Separate PRODUCT_REFERENCE_PICKER and DELETED_PRODUCT_REFERENCE cases This improves readability and maintainability of the switch statement. --------- Co-authored-by: github-actions --- .../product-collection/edit/ProductPicker.tsx | 43 ++--- .../product-collection/edit/editor.scss | 4 + .../blocks/product-collection/edit/index.tsx | 90 ++++++----- .../inspector-advanced-controls/index.tsx | 4 +- .../edit/inspector-controls/index.tsx | 4 +- .../edit/product-collection-content.tsx | 4 +- .../edit/toolbar-controls/index.tsx | 4 +- .../js/blocks/product-collection/types.ts | 10 +- .../js/blocks/product-collection/utils.tsx | 152 ++++++++++++------ .../product-collection.page.ts | 5 +- ...duct-collection-tester.block_theme.spec.ts | 80 +++++++++ ...ollection-handling-missing-product-context | 4 + 12 files changed, 290 insertions(+), 114 deletions(-) create mode 100644 plugins/woocommerce/changelog/51114-add-44878-product-collection-handling-missing-product-context diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx index 3d80edf035c..927d3d31abc 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx @@ -21,15 +21,36 @@ import { import type { ProductCollectionEditComponentProps } from '../types'; import { getCollectionByName } from '../collections'; -const ProductPicker = ( props: ProductCollectionEditComponentProps ) => { +const ProductPicker = ( + props: ProductCollectionEditComponentProps & { + isDeletedProductReference: boolean; + } +) => { const blockProps = useBlockProps(); - const attributes = props.attributes; + const { attributes, isDeletedProductReference } = props; const collection = getCollectionByName( attributes.collection ); if ( ! collection ) { - return; + return null; } + const infoText = isDeletedProductReference + ? __( + 'Previously selected product is no longer available.', + 'woocommerce' + ) + : createInterpolateElement( + sprintf( + /* translators: %s: collection title */ + __( + '%s requires a product to be selected in order to display associated items.', + 'woocommerce' + ), + collection.title + ), + { strong: } + ); + return (
@@ -38,21 +59,7 @@ const ProductPicker = ( props: ProductCollectionEditComponentProps ) => { icon={ info } className="wc-blocks-product-collection__info-icon" /> - - { createInterpolateElement( - sprintf( - /* translators: %s: collection title */ - __( - '%s requires a product to be selected in order to display associated items.', - 'woocommerce' - ), - collection.title - ), - { - strong: , - } - ) } - + { infoText } { @@ -31,49 +33,65 @@ const Edit = ( props: ProductCollectionEditComponentProps ) => { [ clientId ] ); - const productCollectionUIStateInEditor = - getProductCollectionUIStateInEditor( { - hasInnerBlocks, + const { productCollectionUIStateInEditor, isLoading } = + useProductCollectionUIState( { location, - attributes: props.attributes, + attributes, + hasInnerBlocks, usesReference: props.usesReference, } ); - /** - * Component to render based on the UI state. - */ - let Component, - isUsingReferencePreviewMode = false; - switch ( productCollectionUIStateInEditor ) { - case ProductCollectionUIStatesInEditor.COLLECTION_PICKER: - Component = ProductCollectionPlaceholder; - break; - case ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER: - Component = ProductPicker; - break; - case ProductCollectionUIStatesInEditor.VALID: - Component = ProductCollectionContent; - break; - case ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW: - Component = ProductCollectionContent; - isUsingReferencePreviewMode = true; - break; - default: - // By default showing collection chooser. - Component = ProductCollectionPlaceholder; + // Show spinner while calculating Editor UI state. + if ( isLoading ) { + return ( + + + + ); } + const productCollectionContentProps: ProductCollectionContentProps = { + ...props, + openCollectionSelectionModal: () => setIsSelectionModalOpen( true ), + location, + isUsingReferencePreviewMode: + productCollectionUIStateInEditor === + ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW, + }; + + const renderComponent = () => { + switch ( productCollectionUIStateInEditor ) { + case ProductCollectionUIStatesInEditor.COLLECTION_PICKER: + return ; + case ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER: + return ( + + ); + case ProductCollectionUIStatesInEditor.DELETED_PRODUCT_REFERENCE: + return ( + + ); + case ProductCollectionUIStatesInEditor.VALID: + case ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW: + return ( + + ); + default: + return ; + } + }; + return ( <> - - setIsSelectionModalOpen( true ) - } - isUsingReferencePreviewMode={ isUsingReferencePreviewMode } - location={ location } - usesReference={ props.usesReference } - /> + { renderComponent() } { isSelectionModalOpen && ( + props: ProductCollectionContentProps ) { const { clientId, attributes, setAttributes } = props; const { forcePageReload } = attributes; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx index a55d9dbb84a..cf281729f24 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx @@ -27,7 +27,7 @@ import { import metadata from '../../block.json'; import { useTracksLocation } from '../../tracks-utils'; import { - ProductCollectionEditComponentProps, + ProductCollectionContentProps, ProductCollectionAttributes, CoreFilterNames, FilterName, @@ -58,7 +58,7 @@ const prepareShouldShowFilter = }; const ProductCollectionInspectorControls = ( - props: ProductCollectionEditComponentProps + props: ProductCollectionContentProps ) => { const { attributes, context, setAttributes } = props; const { query, hideControls, displayLayout } = attributes; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx index 35714946c42..4eaf299d034 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx @@ -18,7 +18,7 @@ import fastDeepEqual from 'fast-deep-equal/es6'; import type { ProductCollectionAttributes, ProductCollectionQuery, - ProductCollectionEditComponentProps, + ProductCollectionContentProps, } from '../types'; import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants'; import { @@ -68,7 +68,7 @@ const useQueryId = ( const ProductCollectionContent = ( { preview: { setPreviewState, initialPreviewState } = {}, ...props -}: ProductCollectionEditComponentProps ) => { +}: ProductCollectionContentProps ) => { const isInitialAttributesSet = useRef( false ); const { clientId, diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx index c7252aab36e..3808dfdf120 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx @@ -11,10 +11,10 @@ import { setQueryAttribute } from '../../utils'; import DisplaySettingsToolbar from './display-settings-toolbar'; import DisplayLayoutToolbar from './display-layout-toolbar'; import CollectionChooserToolbar from './collection-chooser-toolbar'; -import type { ProductCollectionEditComponentProps } from '../../types'; +import type { ProductCollectionContentProps } from '../../types'; export default function ToolbarControls( - props: Omit< ProductCollectionEditComponentProps, 'preview' > + props: ProductCollectionContentProps ) { const { attributes, openCollectionSelectionModal, setAttributes } = props; const { query, displayLayout } = attributes; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts index 55a8ee9b460..4d37c928d7e 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts @@ -14,9 +14,9 @@ export enum ProductCollectionUIStatesInEditor { PRODUCT_REFERENCE_PICKER = 'product_context_picker', VALID_WITH_PREVIEW = 'uses_reference_preview_mode', VALID = 'valid', + DELETED_PRODUCT_REFERENCE = 'deleted_product_reference', // Future states // INVALID = 'invalid', - // DELETED_PRODUCT_REFERENCE = 'deleted_product_reference', } export interface ProductCollectionAttributes { @@ -110,7 +110,6 @@ export interface ProductCollectionQuery { export type ProductCollectionEditComponentProps = BlockEditProps< ProductCollectionAttributes > & { - openCollectionSelectionModal: () => void; preview?: { initialPreviewState?: PreviewState; setPreviewState?: SetPreviewState; @@ -119,8 +118,13 @@ export type ProductCollectionEditComponentProps = context: { templateSlug: string; }; - isUsingReferencePreviewMode: boolean; + }; + +export type ProductCollectionContentProps = + ProductCollectionEditComponentProps & { location: WooCommerceBlockLocation; + isUsingReferencePreviewMode: boolean; + openCollectionSelectionModal: () => void; }; export type TProductCollectionOrder = 'asc' | 'desc'; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx index 0565027bfe1..4e8ebc23fab 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx @@ -3,10 +3,16 @@ */ import { store as blockEditorStore } from '@wordpress/block-editor'; import { addFilter } from '@wordpress/hooks'; -import { select } from '@wordpress/data'; +import { select, useSelect } from '@wordpress/data'; +import { store as coreDataStore } from '@wordpress/core-data'; import { isWpVersion } from '@woocommerce/settings'; import type { BlockEditProps, Block } from '@wordpress/blocks'; -import { useEffect, useLayoutEffect, useState } from '@wordpress/element'; +import { + useEffect, + useLayoutEffect, + useState, + useMemo, +} from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import type { ProductResponseItem } from '@woocommerce/types'; import { getProduct } from '@woocommerce/editor-components/utils'; @@ -193,7 +199,7 @@ export const getUsesReferencePreviewMessage = ( return ''; }; -export const getProductCollectionUIStateInEditor = ( { +export const useProductCollectionUIState = ( { location, usesReference, attributes, @@ -203,59 +209,111 @@ export const getProductCollectionUIStateInEditor = ( { usesReference?: string[] | undefined; attributes: ProductCollectionAttributes; hasInnerBlocks: boolean; -} ): ProductCollectionUIStatesInEditor => { - const isInRequiredLocation = usesReference?.includes( location.type ); - const isCollectionSelected = !! attributes.collection; +} ) => { + // Fetch product to check if it's deleted. + // `product` will be undefined if it doesn't exist. + const productId = attributes.query?.productReference; + const { product, hasResolved } = useSelect( + ( selectFunc ) => { + if ( ! productId ) { + return { product: null, hasResolved: true }; + } - /** - * Case 1: Product context picker - */ - const isProductContextRequired = usesReference?.includes( 'product' ); - const isProductContextSelected = - ( attributes.query?.productReference ?? null ) !== null; - if ( - isCollectionSelected && - isProductContextRequired && - ! isInRequiredLocation && - ! isProductContextSelected - ) { - return ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER; - } + const { getEntityRecord, hasFinishedResolution } = + selectFunc( coreDataStore ); + const selectorArgs = [ 'postType', 'product', productId ]; + return { + product: getEntityRecord( ...selectorArgs ), + hasResolved: hasFinishedResolution( + 'getEntityRecord', + selectorArgs + ), + }; + }, + [ productId ] + ); + + const productCollectionUIStateInEditor = useMemo( () => { + const isInRequiredLocation = usesReference?.includes( location.type ); + const isCollectionSelected = !! attributes.collection; - /** - * Case 2: Preview mode - based on `usesReference` value - */ - if ( isInRequiredLocation ) { /** - * Block shouldn't be in preview mode when: - * 1. Current location is archive and termId is available. - * 2. Current location is product and productId is available. - * - * Because in these cases, we have required context on the editor side. + * Case 1: Product context picker */ - const isArchiveLocationWithTermId = - location.type === LocationType.Archive && - ( location.sourceData?.termId ?? null ) !== null; - const isProductLocationWithProductId = - location.type === LocationType.Product && - ( location.sourceData?.productId ?? null ) !== null; - + const isProductContextRequired = usesReference?.includes( 'product' ); + const isProductContextSelected = + ( attributes.query?.productReference ?? null ) !== null; if ( - ! isArchiveLocationWithTermId && - ! isProductLocationWithProductId + isCollectionSelected && + isProductContextRequired && + ! isInRequiredLocation && + ! isProductContextSelected ) { - return ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW; + return ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER; } - } - /** - * Case 3: Collection chooser - */ - if ( ! hasInnerBlocks && ! isCollectionSelected ) { - return ProductCollectionUIStatesInEditor.COLLECTION_PICKER; - } + // Case 2: Deleted product reference + if ( + isCollectionSelected && + isProductContextRequired && + ! isInRequiredLocation && + isProductContextSelected + ) { + const isProductDeleted = + productId && + ( product === undefined || product?.status === 'trash' ); + if ( isProductDeleted ) { + return ProductCollectionUIStatesInEditor.DELETED_PRODUCT_REFERENCE; + } + } - return ProductCollectionUIStatesInEditor.VALID; + /** + * Case 3: Preview mode - based on `usesReference` value + */ + if ( isInRequiredLocation ) { + /** + * Block shouldn't be in preview mode when: + * 1. Current location is archive and termId is available. + * 2. Current location is product and productId is available. + * + * Because in these cases, we have required context on the editor side. + */ + const isArchiveLocationWithTermId = + location.type === LocationType.Archive && + ( location.sourceData?.termId ?? null ) !== null; + const isProductLocationWithProductId = + location.type === LocationType.Product && + ( location.sourceData?.productId ?? null ) !== null; + + if ( + ! isArchiveLocationWithTermId && + ! isProductLocationWithProductId + ) { + return ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW; + } + } + + /** + * Case 4: Collection chooser + */ + if ( ! hasInnerBlocks && ! isCollectionSelected ) { + return ProductCollectionUIStatesInEditor.COLLECTION_PICKER; + } + + return ProductCollectionUIStatesInEditor.VALID; + }, [ + location.type, + location.sourceData?.termId, + location.sourceData?.productId, + usesReference, + attributes.collection, + productId, + product, + hasInnerBlocks, + attributes.query?.productReference, + ] ); + + return { productCollectionUIStateInEditor, isLoading: ! hasResolved }; }; export const useSetPreviewState = ( { diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts index 1a87ebeb605..3b94f037eeb 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts @@ -207,7 +207,8 @@ class ProductCollectionPage { } async chooseProductInEditorProductPickerIfAvailable( - pageReference: Page | FrameLocator + pageReference: Page | FrameLocator, + productName = 'Album' ) { const editorProductPicker = pageReference.locator( SELECTORS.productPicker @@ -217,7 +218,7 @@ class ProductCollectionPage { await editorProductPicker .locator( 'label' ) .filter( { - hasText: 'Album', + hasText: productName, } ) .click(); } diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts index a7ea710f8a4..6fd09e4050c 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts @@ -356,4 +356,84 @@ test.describe( 'Product Collection registration', () => { await expect( previewButtonLocator ).toBeHidden(); } ); } ); + + test( 'Product picker should be shown when selected product is deleted', async ( { + pageObject, + admin, + editor, + requestUtils, + page, + } ) => { + // Add a new test product to the database + let testProductId: number | null = null; + const newProduct = await requestUtils.rest( { + method: 'POST', + path: 'wc/v3/products', + data: { + name: 'A Test Product', + price: 10, + }, + } ); + testProductId = newProduct.id; + + await admin.createNewPost(); + await pageObject.insertProductCollection(); + await pageObject.chooseCollectionInPost( + 'myCustomCollectionWithProductContext' + ); + + // Verify that product picker is shown in Editor + const editorProductPicker = editor.canvas.locator( + SELECTORS.productPicker + ); + await expect( editorProductPicker ).toBeVisible(); + + // Once a product is selected, the product picker should be hidden + await pageObject.chooseProductInEditorProductPickerIfAvailable( + editor.canvas, + 'A Test Product' + ); + await expect( editorProductPicker ).toBeHidden(); + + await editor.saveDraft(); + + // Delete the product + await requestUtils.rest( { + method: 'DELETE', + path: `wc/v3/products/${ testProductId }`, + } ); + + // Product picker should be shown in Editor + await admin.page.reload(); + const deletedProductPicker = editor.canvas.getByText( + 'Previously selected product' + ); + await expect( deletedProductPicker ).toBeVisible(); + + // Change status from "trash" to "publish" + await requestUtils.rest( { + method: 'PUT', + path: `wc/v3/products/${ testProductId }`, + data: { + status: 'publish', + }, + } ); + + // Product Picker shouldn't be shown as product is available now + await page.reload(); + await expect( editorProductPicker ).toBeHidden(); + + // Delete the product from database, instead of trashing it + await requestUtils.rest( { + method: 'DELETE', + path: `wc/v3/products/${ testProductId }`, + params: { + // Bypass trash and permanently delete the product + force: true, + }, + } ); + + // Product picker should be shown in Editor + await expect( deletedProductPicker ).toBeVisible(); + } ); } ); diff --git a/plugins/woocommerce/changelog/51114-add-44878-product-collection-handling-missing-product-context b/plugins/woocommerce/changelog/51114-add-44878-product-collection-handling-missing-product-context new file mode 100644 index 00000000000..5e5b6821ab3 --- /dev/null +++ b/plugins/woocommerce/changelog/51114-add-44878-product-collection-handling-missing-product-context @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Product Collection: Added Editor UI for missing product reference \ No newline at end of file From acc313a9b26d0874f2f6437c071a51eeb05e645b Mon Sep 17 00:00:00 2001 From: Ivan Stojadinov Date: Tue, 17 Sep 2024 10:43:43 +0200 Subject: [PATCH 18/36] [e2e] External - Expand WPCOM suite, part 2 (#51414) * Add /merchant tests to the WPCOM suite * Skip "can manually add a variation" on WPCOM * Skip "Coupon management" - API returns 500 on delete * Resolve `wp-block-woocommerce-checkout-order-summary-block` on the first element * Add changefile(s) from automation for the following project(s): woocommerce --------- Co-authored-by: github-actions --- ...1414-e2e-external-expand-wpcom-suite-part2 | 4 + .../envs/default-wpcom/playwright.config.js | 8 + .../merchant/create-checkout-block.spec.js | 8 +- .../tests/merchant/create-coupon.spec.js | 190 +++++++++--------- .../create-variations.spec.js | 186 +++++++++-------- 5 files changed, 215 insertions(+), 181 deletions(-) create mode 100644 plugins/woocommerce/changelog/51414-e2e-external-expand-wpcom-suite-part2 diff --git a/plugins/woocommerce/changelog/51414-e2e-external-expand-wpcom-suite-part2 b/plugins/woocommerce/changelog/51414-e2e-external-expand-wpcom-suite-part2 new file mode 100644 index 00000000000..806a0b91679 --- /dev/null +++ b/plugins/woocommerce/changelog/51414-e2e-external-expand-wpcom-suite-part2 @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Expand the e2e suite we're running on WPCOM part #2. \ No newline at end of file diff --git a/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js b/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js index 276458fa570..27ec548c34a 100644 --- a/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js +++ b/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js @@ -15,6 +15,14 @@ config = { '**/admin-tasks/**/*.spec.js', '**/shopper/**/*.spec.js', '**/api-tests/**/*.test.js', + '**/merchant/products/add-variable-product/**/*.spec.js', + '**/merchant/command-palette.spec.js', + '**/merchant/create-cart-block.spec.js', + '**/merchant/create-checkout-block.spec.js', + '**/merchant/create-coupon.spec.js', + '**/merchant/create-order.spec.js', + '**/merchant/create-page.spec.js', + '**/merchant/create-post.spec.js', ], grepInvert: /@skip-on-default-wpcom/, }, diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-checkout-block.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-checkout-block.spec.js index c5f0d7ec448..7279c31fbea 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-checkout-block.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-checkout-block.spec.js @@ -149,9 +149,11 @@ test.describe( .locator( 'legend' ) ).toBeVisible(); await expect( - page.locator( - '.wp-block-woocommerce-checkout-order-summary-block' - ) + page + .locator( + '.wp-block-woocommerce-checkout-order-summary-block' + ) + .first() ).toBeVisible(); await expect( page.locator( '.wc-block-components-address-form' ).first() diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-coupon.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-coupon.spec.js index 2630674a42b..1d38fd68d5c 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-coupon.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-coupon.spec.js @@ -39,102 +39,110 @@ const test = baseTest.extend( { }, } ); -test.describe( 'Coupon management', { tag: '@services' }, () => { - for ( const couponType of Object.keys( couponData ) ) { - test( `can create new ${ couponType } coupon`, async ( { - page, - coupon, - } ) => { - await test.step( 'add new coupon', async () => { - await page.goto( - 'wp-admin/post-new.php?post_type=shop_coupon' - ); - await page - .getByLabel( 'Coupon code' ) - .fill( couponData[ couponType ].code ); - await page - .getByPlaceholder( 'Description (optional)' ) - .fill( couponData[ couponType ].description ); - await page - .getByPlaceholder( '0' ) - .fill( couponData[ couponType ].amount ); +test.describe( + 'Coupon management', + { tag: [ '@services', '@skip-on-default-wpcom' ] }, + () => { + for ( const couponType of Object.keys( couponData ) ) { + test( `can create new ${ couponType } coupon`, async ( { + page, + coupon, + } ) => { + await test.step( 'add new coupon', async () => { + await page.goto( + 'wp-admin/post-new.php?post_type=shop_coupon' + ); + await page + .getByLabel( 'Coupon code' ) + .fill( couponData[ couponType ].code ); + await page + .getByPlaceholder( 'Description (optional)' ) + .fill( couponData[ couponType ].description ); + await page + .getByPlaceholder( '0' ) + .fill( couponData[ couponType ].amount ); - // set expiry date if it was provided + // set expiry date if it was provided + if ( couponData[ couponType ].expiryDate ) { + await page + .getByPlaceholder( 'yyyy-mm-dd' ) + .fill( couponData[ couponType ].expiryDate ); + } + + // be explicit about whether free shipping is allowed + if ( couponData[ couponType ].freeShipping ) { + await page.getByLabel( 'Allow free shipping' ).check(); + } else { + await page + .getByLabel( 'Allow free shipping' ) + .uncheck(); + } + } ); + + // publish the coupon and retrieve the id + await test.step( 'publish the coupon', async () => { + await expect( + page.getByRole( 'link', { name: 'Move to Trash' } ) + ).toBeVisible(); + await page + .getByRole( 'button', { name: 'Publish', exact: true } ) + .click(); + await expect( + page.getByText( 'Coupon updated.' ) + ).toBeVisible(); + coupon.id = page.url().match( /(?<=post=)\d+/ )[ 0 ]; + expect( coupon.id ).toBeDefined(); + } ); + + // verify the creation of the coupon and details + await test.step( 'verify coupon creation', async () => { + await page.goto( + 'wp-admin/edit.php?post_type=shop_coupon' + ); + await expect( + page.getByRole( 'cell', { + name: couponData[ couponType ].code, + } ) + ).toBeVisible(); + await expect( + page.getByRole( 'cell', { + name: couponData[ couponType ].description, + } ) + ).toBeVisible(); + await expect( + page.getByRole( 'cell', { + name: couponData[ couponType ].amount, + exact: true, + } ) + ).toBeVisible(); + } ); + + // check expiry date if it was set if ( couponData[ couponType ].expiryDate ) { - await page - .getByPlaceholder( 'yyyy-mm-dd' ) - .fill( couponData[ couponType ].expiryDate ); + await test.step( 'verify coupon expiry date', async () => { + await page + .getByText( couponData[ couponType ].code ) + .last() + .click(); + await expect( + page.getByPlaceholder( 'yyyy-mm-dd' ) + ).toHaveValue( couponData[ couponType ].expiryDate ); + } ); } - // be explicit about whether free shipping is allowed + // if it was a free shipping coupon check that if ( couponData[ couponType ].freeShipping ) { - await page.getByLabel( 'Allow free shipping' ).check(); - } else { - await page.getByLabel( 'Allow free shipping' ).uncheck(); + await test.step( 'verify free shipping', async () => { + await page + .getByText( couponData[ couponType ].code ) + .last() + .click(); + await expect( + page.getByLabel( 'Allow free shipping' ) + ).toBeChecked(); + } ); } } ); - - // publish the coupon and retrieve the id - await test.step( 'publish the coupon', async () => { - await expect( - page.getByRole( 'link', { name: 'Move to Trash' } ) - ).toBeVisible(); - await page - .getByRole( 'button', { name: 'Publish', exact: true } ) - .click(); - await expect( - page.getByText( 'Coupon updated.' ) - ).toBeVisible(); - coupon.id = page.url().match( /(?<=post=)\d+/ )[ 0 ]; - expect( coupon.id ).toBeDefined(); - } ); - - // verify the creation of the coupon and details - await test.step( 'verify coupon creation', async () => { - await page.goto( 'wp-admin/edit.php?post_type=shop_coupon' ); - await expect( - page.getByRole( 'cell', { - name: couponData[ couponType ].code, - } ) - ).toBeVisible(); - await expect( - page.getByRole( 'cell', { - name: couponData[ couponType ].description, - } ) - ).toBeVisible(); - await expect( - page.getByRole( 'cell', { - name: couponData[ couponType ].amount, - exact: true, - } ) - ).toBeVisible(); - } ); - - // check expiry date if it was set - if ( couponData[ couponType ].expiryDate ) { - await test.step( 'verify coupon expiry date', async () => { - await page - .getByText( couponData[ couponType ].code ) - .last() - .click(); - await expect( - page.getByPlaceholder( 'yyyy-mm-dd' ) - ).toHaveValue( couponData[ couponType ].expiryDate ); - } ); - } - - // if it was a free shipping coupon check that - if ( couponData[ couponType ].freeShipping ) { - await test.step( 'verify free shipping', async () => { - await page - .getByText( couponData[ couponType ].code ) - .last() - .click(); - await expect( - page.getByLabel( 'Allow free shipping' ) - ).toBeChecked(); - } ); - } - } ); + } } -} ); +); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-variations.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-variations.spec.js index b1271496bf6..16f2965b3b4 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-variations.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-variations.spec.js @@ -84,105 +84,117 @@ test.describe( 'Add variations', { tag: '@gutenberg' }, () => { } } ); - test( 'can manually add a variation', async ( { page } ) => { - await test.step( `Open "Edit product" page of product id ${ productId_addManually }`, async () => { - await page.goto( - `/wp-admin/post.php?post=${ productId_addManually }&action=edit` - ); - } ); - - // hook up the woocommerce_variations_added jQuery trigger so we can check if it's fired - await test.step( 'Hook up the woocommerce_variations_added jQuery trigger', async () => { - await page.evaluate( () => { - window.woocommerceVariationsAddedFunctionCalls = []; - - window - .jQuery( '#variable_product_options' ) - .on( 'woocommerce_variations_added', ( event, data ) => { - window.woocommerceVariationsAddedFunctionCalls.push( [ - event, - data, - ] ); - } ); + test( + 'can manually add a variation', + { tag: '@skip-on-default-wpcom' }, + async ( { page } ) => { + await test.step( `Open "Edit product" page of product id ${ productId_addManually }`, async () => { + await page.goto( + `/wp-admin/post.php?post=${ productId_addManually }&action=edit` + ); } ); - } ); - await test.step( 'Click on the "Variations" tab.', async () => { - await page.locator( '.variations_tab' ).click(); - } ); + // hook up the woocommerce_variations_added jQuery trigger so we can check if it's fired + await test.step( 'Hook up the woocommerce_variations_added jQuery trigger', async () => { + await page.evaluate( () => { + window.woocommerceVariationsAddedFunctionCalls = []; - await test.step( `Manually add ${ variationsToManuallyCreate.length } variations`, async () => { - const variationRows = page.locator( '.woocommerce_variation h3' ); - let variationRowsCount = await variationRows.count(); - const originalVariationRowsCount = variationRowsCount; - - for ( const variationToCreate of variationsToManuallyCreate ) { - await test.step( 'Click "Add manually"', async () => { - const addManuallyButton = page.getByRole( 'button', { - name: 'Add manually', - } ); - - await addManuallyButton.click(); - - await expect( variationRows ).toHaveCount( - ++variationRowsCount - ); - - // verify that the woocommerce_variations_added jQuery trigger was fired - const woocommerceVariationsAddedFunctionCalls = - await page.evaluate( - () => window.woocommerceVariationsAddedFunctionCalls + window + .jQuery( '#variable_product_options' ) + .on( + 'woocommerce_variations_added', + ( event, data ) => { + window.woocommerceVariationsAddedFunctionCalls.push( + [ event, data ] + ); + } ); - expect( - woocommerceVariationsAddedFunctionCalls.length - ).toEqual( - variationRowsCount - originalVariationRowsCount - ); } ); + } ); - for ( const attributeValue of variationToCreate ) { - const attributeName = productAttributes.find( - ( { options } ) => options.includes( attributeValue ) - ).name; - const addAttributeMenu = variationRows - .nth( 0 ) - .locator( 'select', { - has: page.locator( 'option', { - hasText: attributeValue, - } ), + await test.step( 'Click on the "Variations" tab.', async () => { + await page.locator( '.variations_tab' ).click(); + } ); + + await test.step( `Manually add ${ variationsToManuallyCreate.length } variations`, async () => { + const variationRows = page.locator( + '.woocommerce_variation h3' + ); + let variationRowsCount = await variationRows.count(); + const originalVariationRowsCount = variationRowsCount; + + for ( const variationToCreate of variationsToManuallyCreate ) { + await test.step( 'Click "Add manually"', async () => { + const addManuallyButton = page.getByRole( 'button', { + name: 'Add manually', } ); - await test.step( `Select "${ attributeValue }" from the "${ attributeName }" attribute menu`, async () => { - await addAttributeMenu.selectOption( attributeValue ); + await addManuallyButton.click(); + + await expect( variationRows ).toHaveCount( + ++variationRowsCount + ); + + // verify that the woocommerce_variations_added jQuery trigger was fired + const woocommerceVariationsAddedFunctionCalls = + await page.evaluate( + () => + window.woocommerceVariationsAddedFunctionCalls + ); + expect( + woocommerceVariationsAddedFunctionCalls.length + ).toEqual( + variationRowsCount - originalVariationRowsCount + ); } ); - } - - await test.step( 'Click "Save changes"', async () => { - await page - .getByRole( 'button', { - name: 'Save changes', - } ) - .click(); - } ); - - await test.step( `Expect the variation ${ variationToCreate.join( - ', ' - ) } to be successfully saved.`, async () => { - let newlyAddedVariationRow; for ( const attributeValue of variationToCreate ) { - newlyAddedVariationRow = ( - newlyAddedVariationRow || variationRows - ).filter( { - has: page.locator( 'option[selected]', { - hasText: attributeValue, - } ), + const attributeName = productAttributes.find( + ( { options } ) => + options.includes( attributeValue ) + ).name; + const addAttributeMenu = variationRows + .nth( 0 ) + .locator( 'select', { + has: page.locator( 'option', { + hasText: attributeValue, + } ), + } ); + + await test.step( `Select "${ attributeValue }" from the "${ attributeName }" attribute menu`, async () => { + await addAttributeMenu.selectOption( + attributeValue + ); } ); } - await expect( newlyAddedVariationRow ).toBeVisible(); - } ); - } - } ); - } ); + await test.step( 'Click "Save changes"', async () => { + await page + .getByRole( 'button', { + name: 'Save changes', + } ) + .click(); + } ); + + await test.step( `Expect the variation ${ variationToCreate.join( + ', ' + ) } to be successfully saved.`, async () => { + let newlyAddedVariationRow; + + for ( const attributeValue of variationToCreate ) { + newlyAddedVariationRow = ( + newlyAddedVariationRow || variationRows + ).filter( { + has: page.locator( 'option[selected]', { + hasText: attributeValue, + } ), + } ); + } + + await expect( newlyAddedVariationRow ).toBeVisible(); + } ); + } + } ); + } + ); } ); From 8b7050f9b1a276b99e3ccb941fd6566f783ff1bf Mon Sep 17 00:00:00 2001 From: Vladimir Reznichenko Date: Tue, 17 Sep 2024 12:34:40 +0200 Subject: [PATCH 19/36] Monorepo: git post-checkout hook to verify pnpm version and update dependecies. (#51382) --- .husky/post-checkout | 35 +++++++++++++++++++++++++++++++++++ .npmrc | 2 ++ 2 files changed, 37 insertions(+) create mode 100755 .husky/post-checkout diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100755 index 00000000000..cddb5753bc3 --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +. "$(dirname "$0")/_/husky.sh" + +# '1' is branch +CHECKOUT_TYPE=$3 +redColoured='\033[0;31m' +whiteColoured='\033[0m' + +if [ "$CHECKOUT_TYPE" = '1' ]; then + canUpdateDependencies='no' + + # Prompt about pnpm versions mismatch when switching between branches. + currentPnpmVersion=$( ( command -v pnpm > /dev/null && pnpm -v ) || echo 'n/a' ) + targetPnpmVersion=$( grep packageManager package.json | sed -nr 's/.+packageManager.+pnpm@([[:digit:].]+).+/\1/p' ) + if [ "$currentPnpmVersion" != "$targetPnpmVersion" ]; then + printf "${redColoured}pnpm versions mismatch: in use '$currentPnpmVersion', needed '$targetPnpmVersion'. Here some hints how to solve this:\n" + printf "${redColoured}* actualize environment: 'nvm use && pnpm -v' (the most common case)\n" + printf "${redColoured}* install: 'npm install -g pnpm@$targetPnpmVersion'\n" + else + canUpdateDependencies='yes' + fi + + # Auto-refresh dependencies when switching between branches. + changedManifests=$( ( git diff --name-only HEAD ORIG_HEAD | grep -E '(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|composer.json|composer.lock)$' ) || echo '' ) + if [ -n "$changedManifests" ]; then + printf "${whiteColoured}It was a change in the following file(s) - refreshing dependencies:\n" + printf "${whiteColoured} %s\n" $changedManifests + + if [ "$canUpdateDependencies" = 'yes' ]; then + pnpm install --frozen-lockfile + else + printf "${redColoured}Skipping dependencies refresh. Please actualize pnpm version and execute 'pnpm install --frozen-lockfile' manually.\n" + fi + fi +fi diff --git a/.npmrc b/.npmrc index 9d43c15d3cc..a140ea6b576 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,5 @@ ; adding this as npm 7 automatically installs peer dependencies but pnpm does not auto-install-peers=true strict-peer-dependencies=false +; See https://github.com/pnpm/pnpm/pull/8363 (we adding the setting now, to not miss when migrating to pnpm 9.7+) +manage-package-manager-versions=true From e35429538776c9984e6a506ca0354792584afcc6 Mon Sep 17 00:00:00 2001 From: Karol Manijak <20098064+kmanijak@users.noreply.github.com> Date: Tue, 17 Sep 2024 12:44:15 +0200 Subject: [PATCH 20/36] Update Markdown lint allowing the same headings if they're not siblings (#51438) * Set markdown rule about Multiple headings to check siblings only * Change the doc to have same headings but not siblings as example * Add changelog * Update docs manifest --- .markdownlint.json | 4 ++-- docs/docs-manifest.json | 4 ++-- docs/product-collection-block/dom-events.md | 8 ++++---- plugins/woocommerce/changelog/doc-update-markdown-lint | 5 +++++ 4 files changed, 13 insertions(+), 8 deletions(-) create mode 100644 plugins/woocommerce/changelog/doc-update-markdown-lint diff --git a/.markdownlint.json b/.markdownlint.json index 5e29a079a84..4a2dd1c3ec4 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -3,8 +3,8 @@ "MD003": { "style": "atx" }, "MD007": { "indent": 4 }, "MD013": { "line_length": 9999 }, - "MD024": { "allow_different_nesting": true }, - "MD033": { "allowed_elements": ["video"] }, + "MD024": { "siblings_only": true }, + "MD033": { "allowed_elements": [ "video" ] }, "no-hard-tabs": false, "whitespace": false } diff --git a/docs/docs-manifest.json b/docs/docs-manifest.json index 45cec2a5a3f..06efcba2d4a 100644 --- a/docs/docs-manifest.json +++ b/docs/docs-manifest.json @@ -1059,7 +1059,7 @@ "menu_title": "DOM Events", "tags": "how-to", "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-collection-block/dom-events.md", - "hash": "59a4b49eb146774d33229bc60ab7d8f74381493f6e7089ca8f0e2d0eb433a7a4", + "hash": "85cffe1cc273621f16f7362b5efe28ede9689cee0a6e87d0d426014bacc24b05", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-collection-block/dom-events.md", "id": "c8d247b91472740075871e6b57a9583d893ac650" } @@ -1804,5 +1804,5 @@ "categories": [] } ], - "hash": "dfe48a2a48d383c1f4e5b5bb4258d369dd4e80ac8582462b16bbfedd879cb0bf" + "hash": "3cad7f812ae15abd4936a536cf56db63b0f1b549e26eeb3427fe45989647d58c" } \ No newline at end of file diff --git a/docs/product-collection-block/dom-events.md b/docs/product-collection-block/dom-events.md index 940e49a929d..608a88d71f1 100644 --- a/docs/product-collection-block/dom-events.md +++ b/docs/product-collection-block/dom-events.md @@ -10,13 +10,13 @@ tags: how-to This event is triggered when Product Collection block was rendered or re-rendered (e.g. due to page change). -### `wc-blocks_product_list_rendered` - `detail` parameters +### `detail` parameters | Parameter | Type | Default value | Description | | ------------------ | ------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `collection` | string | `undefined` | Collection type. It's `undefined` for "create your own" collections as the type is not specified. For other Core collections it can be one of type: `woocommerce/product-collection/best-sellers`, `woocommerce/product-collection/featured`, `woocommerce/product-collection/new-arrivals`, `woocommerce/product-collection/on-sale`, `woocommerce/product-collection/top-rated`. For custom collections it will hold their name. | -### `wc-blocks_product_list_rendered` - Example usage +### Example usage ```javascript window.document.addEventListener( @@ -32,14 +32,14 @@ window.document.addEventListener( This event is triggered when some blocks are clicked in order to view product (redirect to product page). -### `wc-blocks_viewed_product` - `detail` parameters +### `detail` parameters | Parameter | Type | Default value | Description | | ------------------ | ------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `collection` | string | `undefined` | Collection type. It's `undefined` for "create your own" collections as the type is not specified. For other Core collections it can be one of type: `woocommerce/product-collection/best-sellers`, `woocommerce/product-collection/featured`, `woocommerce/product-collection/new-arrivals`, `woocommerce/product-collection/on-sale`, `woocommerce/product-collection/top-rated`. For custom collections it will hold their name. | | `productId` | number | | Product ID | -### `wc-blocks_viewed_product` Example usage +### Example usage ```javascript window.document.addEventListener( diff --git a/plugins/woocommerce/changelog/doc-update-markdown-lint b/plugins/woocommerce/changelog/doc-update-markdown-lint new file mode 100644 index 00000000000..4d3d00c11ea --- /dev/null +++ b/plugins/woocommerce/changelog/doc-update-markdown-lint @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Updating Markdown linter rule + + From 66523872f3aecd1ffb9685e7f82b2a5c128e69fa Mon Sep 17 00:00:00 2001 From: Seghir Nadir Date: Tue, 17 Sep 2024 12:56:15 +0200 Subject: [PATCH 21/36] Support controlling address card state from data store in Checkout block (#51386) * fix line height * Support controlling address card from data store * expand billing address on unsync * Add changefile(s) from automation for the following project(s): woocommerce-blocks * disable linter rule * remove empty section * remove used vars --------- Co-authored-by: github-actions --- .../context/hooks/use-checkout-address.ts | 36 ++++++++--- .../checkout-billing-address-block/block.tsx | 25 +------- .../customer-address.tsx | 11 ++-- .../checkout-shipping-address-block/block.tsx | 10 +-- .../customer-address.tsx | 11 ++-- .../assets/js/data/checkout/action-types.ts | 2 + .../assets/js/data/checkout/actions.ts | 26 ++++++++ .../assets/js/data/checkout/default-state.ts | 27 ++++++-- .../assets/js/data/checkout/reducers.ts | 14 +++++ .../assets/js/data/checkout/selectors.ts | 8 +++ .../extensibility/data-store/checkout.md | 63 ++++++++++++++++++- .../packages/components/panel/style.scss | 1 - ...6-add-control-address-edit-from-data-store | 4 ++ 13 files changed, 180 insertions(+), 58 deletions(-) create mode 100644 plugins/woocommerce/changelog/51386-add-control-address-edit-from-data-store diff --git a/plugins/woocommerce-blocks/assets/js/base/context/hooks/use-checkout-address.ts b/plugins/woocommerce-blocks/assets/js/base/context/hooks/use-checkout-address.ts index 308318d73d3..a69c2d22160 100644 --- a/plugins/woocommerce-blocks/assets/js/base/context/hooks/use-checkout-address.ts +++ b/plugins/woocommerce-blocks/assets/js/base/context/hooks/use-checkout-address.ts @@ -25,7 +25,11 @@ interface CheckoutAddress { setBillingAddress: ( data: Partial< BillingAddress > ) => void; setEmail: ( value: string ) => void; useShippingAsBilling: boolean; + editingBillingAddress: boolean; + editingShippingAddress: boolean; setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void; + setEditingBillingAddress: ( isEditing: boolean ) => void; + setEditingShippingAddress: ( isEditing: boolean ) => void; defaultFields: AddressFields; showShippingFields: boolean; showBillingFields: boolean; @@ -40,15 +44,25 @@ interface CheckoutAddress { */ export const useCheckoutAddress = (): CheckoutAddress => { const { needsShipping } = useShippingData(); - const { useShippingAsBilling, prefersCollection } = useSelect( - ( select ) => ( { - useShippingAsBilling: - select( CHECKOUT_STORE_KEY ).getUseShippingAsBilling(), - prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(), - } ) - ); - const { __internalSetUseShippingAsBilling } = - useDispatch( CHECKOUT_STORE_KEY ); + const { + useShippingAsBilling, + prefersCollection, + editingBillingAddress, + editingShippingAddress, + } = useSelect( ( select ) => ( { + useShippingAsBilling: + select( CHECKOUT_STORE_KEY ).getUseShippingAsBilling(), + prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(), + editingBillingAddress: + select( CHECKOUT_STORE_KEY ).getEditingBillingAddress(), + editingShippingAddress: + select( CHECKOUT_STORE_KEY ).getEditingShippingAddress(), + } ) ); + const { + __internalSetUseShippingAsBilling, + setEditingBillingAddress, + setEditingShippingAddress, + } = useDispatch( CHECKOUT_STORE_KEY ); const { billingAddress, setBillingAddress, @@ -77,6 +91,10 @@ export const useCheckoutAddress = (): CheckoutAddress => { defaultFields, useShippingAsBilling, setUseShippingAsBilling: __internalSetUseShippingAsBilling, + editingBillingAddress, + editingShippingAddress, + setEditingBillingAddress, + setEditingShippingAddress, needsShipping, showShippingFields: ! forcedBillingAddress && needsShipping && ! prefersCollection, diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx index bcdcbac3e69..d56938d94df 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx @@ -7,14 +7,12 @@ import { useCheckoutAddress, useEditorContext, noticeContexts, - useShippingData, } from '@woocommerce/base-context'; import Noninteractive from '@woocommerce/base-components/noninteractive'; import type { ShippingAddress, FormFieldsConfig } from '@woocommerce/settings'; import { StoreNoticesContainer } from '@woocommerce/blocks-components'; import { useSelect } from '@wordpress/data'; import { CART_STORE_KEY } from '@woocommerce/block-data'; -import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies @@ -36,14 +34,9 @@ const Block = ( { showPhoneField: boolean; requirePhoneField: boolean; } ): JSX.Element => { - const { - shippingAddress, - billingAddress, - setShippingAddress, - useBillingAsShipping, - } = useCheckoutAddress(); + const { billingAddress, setShippingAddress, useBillingAsShipping } = + useCheckoutAddress(); const { isEditor } = useEditorContext(); - const { needsShipping } = useShippingData(); // Syncs shipping address with billing address if "Force shipping to the customer billing address" is enabled. useEffectOnce( () => { @@ -101,19 +94,6 @@ const Block = ( { }; } ); - // Default editing state for CustomerAddress component comes from the current address and whether or not we're in the editor. - const hasAddress = !! ( - billingAddress.address_1 && - ( billingAddress.first_name || billingAddress.last_name ) - ); - const { email, ...billingAddressWithoutEmail } = billingAddress; - const billingMatchesShipping = isShallowEqual( - billingAddressWithoutEmail, - shippingAddress - ); - const defaultEditingAddress = - isEditor || ! hasAddress || ( needsShipping && billingMatchesShipping ); - return ( <> @@ -121,7 +101,6 @@ const Block = ( { { cartDataLoaded ? ( ) : null } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx index b36708f4314..668fe20cf61 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useState, useCallback, useEffect } from '@wordpress/element'; +import { useCallback, useEffect } from '@wordpress/element'; import { Form } from '@woocommerce/base-components/cart-checkout'; import { useCheckoutAddress, useStoreEvents } from '@woocommerce/base-context'; import type { @@ -20,19 +20,18 @@ import AddressCard from '../../address-card'; const CustomerAddress = ( { addressFieldsConfig, - defaultEditing = false, }: { addressFieldsConfig: FormFieldsConfig; - defaultEditing?: boolean; } ) => { const { billingAddress, setShippingAddress, setBillingAddress, useBillingAsShipping, + editingBillingAddress: editing, + setEditingBillingAddress: setEditing, } = useCheckoutAddress(); const { dispatchCheckoutEvent } = useStoreEvents(); - const [ editing, setEditing ] = useState( defaultEditing ); // Forces editing state if store has errors. const { hasValidationErrors, invalidProps } = useSelect( ( select ) => { @@ -55,7 +54,7 @@ const CustomerAddress = ( { if ( invalidProps.length > 0 && editing === false ) { setEditing( true ); } - }, [ editing, hasValidationErrors, invalidProps.length ] ); + }, [ editing, hasValidationErrors, invalidProps.length, setEditing ] ); const onChangeAddress = useCallback( ( values: AddressFormValues ) => { @@ -86,7 +85,7 @@ const CustomerAddress = ( { isExpanded={ editing } /> ), - [ billingAddress, addressFieldsConfig, editing ] + [ billingAddress, addressFieldsConfig, editing, setEditing ] ); const renderAddressFormComponent = useCallback( diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/block.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/block.tsx index 2ddbb0d7744..e749fa33afe 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/block.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/block.tsx @@ -47,6 +47,7 @@ const Block = ( { billingAddress, useShippingAsBilling, setUseShippingAsBilling, + setEditingBillingAddress, } = useCheckoutAddress(); const { isEditor } = useEditorContext(); const isGuest = getSetting( 'currentUserId' ) === 0; @@ -116,10 +117,6 @@ const Block = ( { const noticeContext = useShippingAsBilling ? [ noticeContexts.SHIPPING_ADDRESS, noticeContexts.BILLING_ADDRESS ] : [ noticeContexts.SHIPPING_ADDRESS ]; - const hasAddress = !! ( - shippingAddress.address_1 && - ( shippingAddress.first_name || shippingAddress.last_name ) - ); const { cartDataLoaded } = useSelect( ( select ) => { const store = select( CART_STORE_KEY ); @@ -128,9 +125,6 @@ const Block = ( { }; } ); - // Default editing state for CustomerAddress component comes from the current address and whether or not we're in the editor. - const defaultEditingAddress = isEditor || ! hasAddress; - return ( <> @@ -138,7 +132,6 @@ const Block = ( { { cartDataLoaded ? ( ) : null } @@ -151,6 +144,7 @@ const Block = ( { if ( checked ) { syncBillingWithShipping(); } else { + setEditingBillingAddress( true ); clearBillingAddress( billingAddress ); } } } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx index 1c21625c188..fed4e47c7ba 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useState, useCallback, useEffect } from '@wordpress/element'; +import { useCallback, useEffect } from '@wordpress/element'; import { Form } from '@woocommerce/base-components/cart-checkout'; import { useCheckoutAddress, useStoreEvents } from '@woocommerce/base-context'; import type { @@ -20,19 +20,18 @@ import AddressCard from '../../address-card'; const CustomerAddress = ( { addressFieldsConfig, - defaultEditing = false, }: { addressFieldsConfig: FormFieldsConfig; - defaultEditing?: boolean; } ) => { const { shippingAddress, setShippingAddress, setBillingAddress, useShippingAsBilling, + editingShippingAddress: editing, + setEditingShippingAddress: setEditing, } = useCheckoutAddress(); const { dispatchCheckoutEvent } = useStoreEvents(); - const [ editing, setEditing ] = useState( defaultEditing ); // Forces editing state if store has errors. const { hasValidationErrors, invalidProps } = useSelect( ( select ) => { @@ -54,7 +53,7 @@ const CustomerAddress = ( { if ( invalidProps.length > 0 && editing === false ) { setEditing( true ); } - }, [ editing, hasValidationErrors, invalidProps.length ] ); + }, [ editing, hasValidationErrors, invalidProps.length, setEditing ] ); const onChangeAddress = useCallback( ( values: AddressFormValues ) => { @@ -85,7 +84,7 @@ const CustomerAddress = ( { isExpanded={ editing } /> ), - [ shippingAddress, addressFieldsConfig, editing ] + [ shippingAddress, addressFieldsConfig, editing, setEditing ] ); const renderAddressFormComponent = useCallback( diff --git a/plugins/woocommerce-blocks/assets/js/data/checkout/action-types.ts b/plugins/woocommerce-blocks/assets/js/data/checkout/action-types.ts index 327cd10bca1..3b1b82a5254 100644 --- a/plugins/woocommerce-blocks/assets/js/data/checkout/action-types.ts +++ b/plugins/woocommerce-blocks/assets/js/data/checkout/action-types.ts @@ -17,4 +17,6 @@ export const ACTION_TYPES = { SET_REDIRECT_URL: 'SET_REDIRECT_URL', SET_SHOULD_CREATE_ACCOUNT: 'SET_SHOULD_CREATE_ACCOUNT', SET_USE_SHIPPING_AS_BILLING: 'SET_USE_SHIPPING_AS_BILLING', + SET_EDITING_BILLING_ADDRESS: 'SET_EDITING_BILLING_ADDRESS', + SET_EDITING_SHIPPING_ADDRESS: 'SET_EDITING_SHIPPING_ADDRESS', } as const; diff --git a/plugins/woocommerce-blocks/assets/js/data/checkout/actions.ts b/plugins/woocommerce-blocks/assets/js/data/checkout/actions.ts index 8cc1f724274..eee54051f65 100644 --- a/plugins/woocommerce-blocks/assets/js/data/checkout/actions.ts +++ b/plugins/woocommerce-blocks/assets/js/data/checkout/actions.ts @@ -118,6 +118,30 @@ export const __internalSetUseShippingAsBilling = ( useShippingAsBilling, } ); +/** + * Set whether the billing address is being edited + * + * @param isEditing True if the billing address is being edited, false otherwise + */ +export const setEditingBillingAddress = ( isEditing: boolean ) => { + return { + type: types.SET_EDITING_BILLING_ADDRESS, + isEditing, + }; +}; + +/** + * Set whether the shipping address is being edited + * + * @param isEditing True if the shipping address is being edited, false otherwise + */ +export const setEditingShippingAddress = ( isEditing: boolean ) => { + return { + type: types.SET_EDITING_SHIPPING_ADDRESS, + isEditing, + }; +}; + /** * Whether an account should be created for the user while checking out * @@ -182,6 +206,8 @@ export type CheckoutAction = | typeof __internalSetCustomerId | typeof __internalSetCustomerPassword | typeof __internalSetUseShippingAsBilling + | typeof setEditingBillingAddress + | typeof setEditingShippingAddress | typeof __internalSetShouldCreateAccount | typeof __internalSetOrderNotes | typeof setPrefersCollection diff --git a/plugins/woocommerce-blocks/assets/js/data/checkout/default-state.ts b/plugins/woocommerce-blocks/assets/js/data/checkout/default-state.ts index 7891c255565..1a82d4d5056 100644 --- a/plugins/woocommerce-blocks/assets/js/data/checkout/default-state.ts +++ b/plugins/woocommerce-blocks/assets/js/data/checkout/default-state.ts @@ -23,8 +23,28 @@ export type CheckoutState = { shouldCreateAccount: boolean; // Should a user account be created? status: STATUS; // Status of the checkout useShippingAsBilling: boolean; // Should the billing form be hidden and inherit the shipping address? + editingBillingAddress: boolean; // Is the billing address being edited? + editingShippingAddress: boolean; // Is the shipping address being edited? }; +// Default editing state for CustomerAddress component comes from the current address and whether or not we're in the editor. +const hasBillingAddress = !! ( + checkoutData.billing_address.address_1 && + ( checkoutData.billing_address.first_name || + checkoutData.billing_address.last_name ) +); + +const hasShippingAddress = !! ( + checkoutData.shipping_address.address_1 && + ( checkoutData.shipping_address.first_name || + checkoutData.shipping_address.last_name ) +); + +const billingMatchesShipping = isSameAddress( + checkoutData.billing_address, + checkoutData.shipping_address +); + export const defaultState: CheckoutState = { additionalFields: checkoutData.additional_fields || {}, calculatingCount: 0, @@ -38,8 +58,7 @@ export const defaultState: CheckoutState = { redirectUrl: '', shouldCreateAccount: false, status: STATUS.IDLE, - useShippingAsBilling: isSameAddress( - checkoutData.billing_address, - checkoutData.shipping_address - ), + useShippingAsBilling: billingMatchesShipping, + editingBillingAddress: ! hasBillingAddress, + editingShippingAddress: ! hasShippingAddress, }; diff --git a/plugins/woocommerce-blocks/assets/js/data/checkout/reducers.ts b/plugins/woocommerce-blocks/assets/js/data/checkout/reducers.ts index 1a01c5772df..01bf428d245 100644 --- a/plugins/woocommerce-blocks/assets/js/data/checkout/reducers.ts +++ b/plugins/woocommerce-blocks/assets/js/data/checkout/reducers.ts @@ -130,6 +130,20 @@ const reducer = ( state = defaultState, action: CheckoutAction ) => { } break; + case types.SET_EDITING_BILLING_ADDRESS: + newState = { + ...state, + editingBillingAddress: action.isEditing, + }; + break; + + case types.SET_EDITING_SHIPPING_ADDRESS: + newState = { + ...state, + editingShippingAddress: action.isEditing, + }; + break; + case types.SET_SHOULD_CREATE_ACCOUNT: if ( action.shouldCreateAccount !== undefined && diff --git a/plugins/woocommerce-blocks/assets/js/data/checkout/selectors.ts b/plugins/woocommerce-blocks/assets/js/data/checkout/selectors.ts index c472bc25bc8..759049db2ba 100644 --- a/plugins/woocommerce-blocks/assets/js/data/checkout/selectors.ts +++ b/plugins/woocommerce-blocks/assets/js/data/checkout/selectors.ts @@ -36,6 +36,14 @@ export const getUseShippingAsBilling = ( state: CheckoutState ) => { return state.useShippingAsBilling; }; +export const getEditingBillingAddress = ( state: CheckoutState ) => { + return state.editingBillingAddress; +}; + +export const getEditingShippingAddress = ( state: CheckoutState ) => { + return state.editingShippingAddress; +}; + export const getExtensionData = ( state: CheckoutState ) => { return state.extensionData; }; diff --git a/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/data-store/checkout.md b/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/data-store/checkout.md index 5badd9d879e..b4a99cc4ecb 100644 --- a/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/data-store/checkout.md +++ b/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/data-store/checkout.md @@ -1,5 +1,7 @@ # Checkout Store (`wc/store/checkout`) + + > 💡 What's the difference between the Cart Store and the Checkout Store? > > The **Cart Store (`wc/store/cart`)** manages and retrieves data about the shopping cart, including items, customer data, and interactions like coupons. @@ -173,6 +175,36 @@ const store = select( CHECKOUT_STORE_KEY ); const useShippingAsBilling = store.getUseShippingAsBilling(); ``` +### getEditingBillingAddress + +Returns true if the billing address is being edited. + +#### _Returns_ + +- `boolean`: True if the billing address is being edited. + +#### _Example_ + +```js +const store = select( CHECKOUT_STORE_KEY ); +const editingBillingAddress = store.getEditingBillingAddress(); +``` + +### getEditingShippingAddress + +Returns true if the shipping address is being edited. + +#### _Returns_ + +- `boolean`: True if the shipping address is being edited. + +#### _Example_ + +```js +const store = select( CHECKOUT_STORE_KEY ); +const editingShippingAddress = store.getEditingShippingAddress(); +``` + ### hasError Returns true if an error occurred, and false otherwise. @@ -293,7 +325,6 @@ const store = select( CHECKOUT_STORE_KEY ); const isCalculating = store.isCalculating(); ``` - ### prefersCollection Returns true if the customer prefers to collect their order, and false otherwise. @@ -326,6 +357,36 @@ const store = dispatch( CHECKOUT_STORE_KEY ); store.setPrefersCollection( true ); ``` +### setEditingBillingAddress + +Set the billing address to editing state or collapsed state. Note that if the address has invalid fields, it will not be set to collapsed state. + +#### _Parameters_ + +- _isEditing_ `boolean`: True to set the billing address to editing state, false to set it to collapsed state. + +#### _Example_ + +```js +const store = dispatch( CHECKOUT_STORE_KEY ); +store.setEditingBillingAddress( true ); +``` + +### setEditingShippingAddress + +Set the shipping address to editing state or collapsed state. Note that if the address has invalid fields, it will not be set to collapsed state. + +#### _Parameters_ + +- _isEditing_ `boolean`: True to set the shipping address to editing state, false to set it to collapsed state. + +#### _Example_ + +```js +const store = dispatch( CHECKOUT_STORE_KEY ); +store.setEditingShippingAddress( true ); +``` + --- diff --git a/plugins/woocommerce-blocks/packages/components/panel/style.scss b/plugins/woocommerce-blocks/packages/components/panel/style.scss index 8df33917d6a..d4651f9b918 100644 --- a/plugins/woocommerce-blocks/packages/components/panel/style.scss +++ b/plugins/woocommerce-blocks/packages/components/panel/style.scss @@ -16,7 +16,6 @@ .wc-block-components-panel__button { box-sizing: border-box; height: auto; - line-height: inherit; margin-top: em(6px); padding-right: #{24px + $gap-smaller}; padding-top: em($gap-small - 6px); diff --git a/plugins/woocommerce/changelog/51386-add-control-address-edit-from-data-store b/plugins/woocommerce/changelog/51386-add-control-address-edit-from-data-store new file mode 100644 index 00000000000..38a81b13fff --- /dev/null +++ b/plugins/woocommerce/changelog/51386-add-control-address-edit-from-data-store @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Move address card state management to data stores in Checkout block. \ No newline at end of file From be17f843b6905af0eaa62aed1a7e148861e53793 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 17 Sep 2024 12:30:22 +0100 Subject: [PATCH 22/36] Add missing `wp-block-` classnames to order confirmation blocks, Store Notices, and Breadcrumbs (#51380) * Add missing wp-block-x classname to order confirmation blocks * Use get_block_wrapper_attributes for store notices block * Breadcrumbs and notices * Changelog --- .../add-missing-wp-block-classnames-49739 | 4 ++++ .../src/Blocks/BlockTypes/Breadcrumbs.php | 12 +++++++----- .../AbstractOrderConfirmationBlock.php | 5 +++-- .../src/Blocks/BlockTypes/StoreNotices.php | 14 ++++++-------- 4 files changed, 20 insertions(+), 15 deletions(-) create mode 100644 plugins/woocommerce/changelog/add-missing-wp-block-classnames-49739 diff --git a/plugins/woocommerce/changelog/add-missing-wp-block-classnames-49739 b/plugins/woocommerce/changelog/add-missing-wp-block-classnames-49739 new file mode 100644 index 00000000000..dc4c7059a56 --- /dev/null +++ b/plugins/woocommerce/changelog/add-missing-wp-block-classnames-49739 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Added missing wp-block- classes to order confirmation, store notices, and breadcrumb blocks. diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Breadcrumbs.php b/plugins/woocommerce/src/Blocks/BlockTypes/Breadcrumbs.php index dc25d2258bd..fb0e0b1cc0e 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/Breadcrumbs.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/Breadcrumbs.php @@ -34,14 +34,16 @@ class Breadcrumbs extends AbstractBlock { return; } - $classname = $attributes['className'] ?? ''; $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes ); return sprintf( - '
%4$s
', - esc_attr( $classes_and_styles['classes'] ), - esc_attr( $classname ), - esc_attr( $classes_and_styles['styles'] ), + '
%2$s
', + get_block_wrapper_attributes( + array( + 'class' => 'wc-block-breadcrumbs woocommerce ' . esc_attr( $classes_and_styles['classes'] ), + 'style' => $classes_and_styles['styles'], + ) + ), $breadcrumb ); } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/AbstractOrderConfirmationBlock.php b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/AbstractOrderConfirmationBlock.php index 4887dd04e30..66b263dbe82 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/AbstractOrderConfirmationBlock.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/AbstractOrderConfirmationBlock.php @@ -55,11 +55,12 @@ abstract class AbstractOrderConfirmationBlock extends AbstractBlock { } return $block_content ? sprintf( - '
%3$s
', + '
%3$s
', esc_attr( trim( $classname ) ), esc_attr( $classes_and_styles['styles'] ), $block_content, - esc_attr( $this->block_name ) + esc_attr( $this->block_name ), + esc_attr( $this->namespace ) ) : ''; } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/StoreNotices.php b/plugins/woocommerce/src/Blocks/BlockTypes/StoreNotices.php index c9c20d21548..9addff1a630 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/StoreNotices.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/StoreNotices.php @@ -45,17 +45,15 @@ class StoreNotices extends AbstractBlock { return; } - $classname = isset( $attributes['className'] ) ? $attributes['className'] : ''; $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes ); - if ( isset( $attributes['align'] ) ) { - $classname .= " align{$attributes['align']}"; - } - return sprintf( - '
%3$s
', - esc_attr( $classes_and_styles['classes'] ), - esc_attr( $classname ), + '
%2$s
', + get_block_wrapper_attributes( + array( + 'class' => 'wc-block-store-notices woocommerce ' . esc_attr( $classes_and_styles['classes'] ), + ) + ), wc_kses_notice( $notices ) ); } From b98236a25ed7691e03db4e04718319ee5e7d417c Mon Sep 17 00:00:00 2001 From: Vladimir Reznichenko Date: Tue, 17 Sep 2024 14:32:01 +0200 Subject: [PATCH 23/36] [dev] CI: experimental version of performance metrics job (#51276) --- .github/workflows/pr-assess-performance.yml | 102 ++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 .github/workflows/pr-assess-performance.yml diff --git a/.github/workflows/pr-assess-performance.yml b/.github/workflows/pr-assess-performance.yml new file mode 100644 index 00000000000..b22c1719cd2 --- /dev/null +++ b/.github/workflows/pr-assess-performance.yml @@ -0,0 +1,102 @@ +name: Performance metrics + +on: + pull_request: + paths: + - 'plugins/woocommerce/composer.*' + - 'plugins/woocommerce/client/admin/config/**' + - 'plugins/woocommerce/includes/**' + - 'plugins/woocommerce/lib/**' + - 'plugins/woocommerce/patterns/**' + - 'plugins/woocommerce/src/**' + - 'plugins/woocommerce/templates/**' + - 'plugins/woocommerce/tests/metrics/**' + - 'plugins/woocommerce/.wp-env.json' + - '.github/workflows/pr-assess-performance.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} + cancel-in-progress: true + +env: + WP_ARTIFACTS_PATH: ${{ github.workspace }}/tools/compare-perf/artifacts/ + +jobs: + benchmark: + name: Evaluate performance metrics + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + name: Checkout (${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}) + with: + fetch-depth: 0 + + - uses: ./.github/actions/setup-woocommerce-monorepo + name: Install Monorepo + with: + install: '@woocommerce/plugin-woocommerce...' + build: '@woocommerce/plugin-woocommerce' + build-type: 'full' + pull-playwright-cache: true + pull-package-deps: '@woocommerce/plugin-woocommerce' + + #TODO: Inject WordPress version as per plugin requirements (relying to defaults currently). + - name: Start Test Environment + run: | + pnpm --filter="@woocommerce/plugin-woocommerce" test:e2e:install & + pnpm --filter="@woocommerce/plugin-woocommerce" env:test + + # TODO: cache results if pushed to trunk + - name: Measure performance (@${{ github.sha }}) + run: | + RESULTS_ID="editor_${{ github.sha }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics editor + RESULTS_ID="product-editor_${{ github.sha }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics product-editor + + # In alignment with .github/workflows/scripts/run-metrics.sh, we should checkout 3d7d7f02017383937f1a4158d433d0e5d44b3dc9 + # as baseline. But to avoid switching branches in 'Analyze results' step, we pick 55f855a2e6d769b5ae44305b2772eb30d3e721df + # which introduced reporting mode for the perf utility. + - name: Checkout (55f855a2e6d769b5ae44305b2772eb30d3e721df@trunk, further references as 'baseline') + run: | + git reset --hard && git checkout 55f855a2e6d769b5ae44305b2772eb30d3e721df + echo "WC_TRUNK_SHA=55f855a2e6d769b5ae44305b2772eb30d3e721df" >> $GITHUB_ENV + + # Artifacts download/upload would be more reliable, but we couldn't make it working... + - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 + name: Cache measurements (baseline) + with: + path: tools/compare-perf/artifacts/*_${{ env.WC_TRUNK_SHA }}_* + key: ${{ runner.os }}-woocommerce-performance-measures-${{ env.WC_TRUNK_SHA }} + + - name: Verify cached measurements (baseline) + run: | + if test -n "$(find tools/compare-perf/artifacts/ -maxdepth 1 -name '*_${{ env.WC_TRUNK_SHA }}_*' -print -quit)" + then + echo "WC_MEASURE_BASELINE=no" >> $GITHUB_ENV + else + ls -l tools/compare-perf/artifacts/ + echo "Triggering baseline benchmarking" + echo "WC_MEASURE_BASELINE=yes" >> $GITHUB_ENV + fi + + - name: Build (baseline) + if: ${{ env.WC_MEASURE_BASELINE == 'yes' }} + run: | + git clean -n -d -X ./packages ./plugins | grep -v vendor | grep -v node_modules | sed -e 's/Would remove //g' | tr '\n' '\0' | xargs -0 rm -r + pnpm install --filter='@woocommerce/plugin-woocommerce...' --frozen-lockfile --config.dedupe-peer-dependents=false + pnpm --filter='@woocommerce/plugin-woocommerce' build + + #TODO: is baseline Wordpress version changes, restart environment targeting it. + + - name: Measure performance (@${{ env.WC_TRUNK_SHA }}) + if: ${{ env.WC_MEASURE_BASELINE == 'yes' }} + run: | + RESULTS_ID="editor_${{ env.WC_TRUNK_SHA }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics editor + RESULTS_ID="product-editor_${{ env.WC_TRUNK_SHA }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics product-editor + + - name: Analyze results + run: | + pnpm install --filter='compare-perf...' --frozen-lockfile --config.dedupe-peer-dependents=false + pnpm --filter="compare-perf" run compare compare-performance ${{ github.sha }} ${{ env.WC_TRUNK_SHA }} --tests-branch ${{ github.sha }} --skip-benchmarking + + # TODO: Publish to CodeVitals (see .github/workflows/scripts/run-metrics.sh) if pushed to trunk From 554434ea3d366ce688c3d945c2e617cf83a32554 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 17 Sep 2024 13:56:01 +0100 Subject: [PATCH 24/36] Cart Shortcode: `wc_get_cart_url` should only return current URL if on the cart page (#51384) * Narrow logic further by only checking if the current page is the cart, not WOOCOMMERCE_CART * changelog --- plugins/woocommerce/changelog/fix-cart-url-page-check-50524 | 4 ++++ plugins/woocommerce/includes/wc-core-functions.php | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 plugins/woocommerce/changelog/fix-cart-url-page-check-50524 diff --git a/plugins/woocommerce/changelog/fix-cart-url-page-check-50524 b/plugins/woocommerce/changelog/fix-cart-url-page-check-50524 new file mode 100644 index 00000000000..dbd4b4aa4bc --- /dev/null +++ b/plugins/woocommerce/changelog/fix-cart-url-page-check-50524 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +wc_get_cart_url should only return current URL if on the cart page. This excludes the usage of WOOCOMMERCE_CART. diff --git a/plugins/woocommerce/includes/wc-core-functions.php b/plugins/woocommerce/includes/wc-core-functions.php index 4158fd900e0..34d4781f00c 100644 --- a/plugins/woocommerce/includes/wc-core-functions.php +++ b/plugins/woocommerce/includes/wc-core-functions.php @@ -1484,7 +1484,11 @@ function wc_transaction_query( $type = 'start', $force = false ) { * @return string Url to cart page */ function wc_get_cart_url() { - if ( is_cart() && isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) { + // We don't use is_cart() here because that also checks for a defined constant. We are only interested in the page. + $page_id = wc_get_page_id( 'cart' ); + $is_cart_page = ( $page_id && is_page( $page_id ) ) || wc_post_content_has_shortcode( 'woocommerce_cart' ); + + if ( $is_cart_page && 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 ); From ad8a25bd635bfeebae427853db0d1fc9bb1ebdd6 Mon Sep 17 00:00:00 2001 From: louwie17 Date: Tue, 17 Sep 2024 16:11:54 +0200 Subject: [PATCH 25/36] Add cron for Storybook pages - daily at 2:30 UTC (#51445) Add cron for storybook pages - daily at 2:30 UTC --- .github/workflows/storybook-pages.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/storybook-pages.yml b/.github/workflows/storybook-pages.yml index 0e84b6df80f..1aea3190d65 100644 --- a/.github/workflows/storybook-pages.yml +++ b/.github/workflows/storybook-pages.yml @@ -1,6 +1,8 @@ name: Storybook GitHub Pages on: + schedule: + - cron: '30 2 * * *' workflow_dispatch: permissions: From bd1bbfd63ccd84a75e880b28b63820423c205afa Mon Sep 17 00:00:00 2001 From: jonathansadowski Date: Tue, 17 Sep 2024 09:16:29 -0500 Subject: [PATCH 26/36] Update upload- and download-artifact versions in workflows (#51073) * Update build zip workflow fo upload artifact v4 * Update download- and upload-artifact to v4 in code freeze workflow --- .github/workflows/build-release-zip-file.yml | 2 +- .github/workflows/release-code-freeze.yml | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-release-zip-file.yml b/.github/workflows/build-release-zip-file.yml index 94ae898d2a6..c6189b41c80 100644 --- a/.github/workflows/build-release-zip-file.yml +++ b/.github/workflows/build-release-zip-file.yml @@ -31,7 +31,7 @@ jobs: run: unzip plugins/woocommerce/woocommerce.zip -d zipfile - name: Upload the zip file as an artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/release-code-freeze.yml b/.github/workflows/release-code-freeze.yml index 633000a1801..68a6304ee93 100644 --- a/.github/workflows/release-code-freeze.yml +++ b/.github/workflows/release-code-freeze.yml @@ -186,7 +186,7 @@ jobs: run: bash bin/build-zip.sh - name: Upload the zip file as an artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -216,7 +216,7 @@ jobs: run: bash bin/build-zip.sh - name: Upload the zip file as an artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -231,7 +231,7 @@ jobs: if: ${{ needs.code-freeze-prep.outputs.isTodayMonthlyFreeze == 'yes' }} steps: - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -279,7 +279,7 @@ jobs: working-directory: tools/monorepo-utils - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -300,7 +300,7 @@ jobs: if: ${{ needs.code-freeze-prep.outputs.isTodayAcceleratedFreeze == 'yes' }} steps: - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -348,7 +348,7 @@ jobs: working-directory: tools/monorepo-utils - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -369,7 +369,7 @@ jobs: if: ${{ needs.code-freeze-prep.outputs.isTodayMonthlyFreeze == 'yes' }} steps: - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -380,7 +380,7 @@ jobs: run: unzip ${{ steps.download.outputs.download-path }}/woocommerce.zip -d zipfile - name: Upload the zip file as an artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -395,7 +395,7 @@ jobs: if: ${{ needs.code-freeze-prep.outputs.isTodayAcceleratedFreeze == 'yes' }} steps: - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -406,7 +406,7 @@ jobs: run: unzip ${{ steps.download.outputs.download-path }}/woocommerce.zip -d zipfile - name: Upload the zip file as an artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From dee8c619f087ee17a096bde5d22937a13bf151d7 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 17 Sep 2024 22:31:44 +0800 Subject: [PATCH 27/36] Refine PHP Fatal Error Counting (#51363) * Refine PHP Fatal Error Counting in MC Stat * Add changelog * Fix import --- .../changelog/update-refine-error-counting | 4 ++++ plugins/woocommerce/includes/class-woocommerce.php | 7 ------- .../src/Internal/Logging/RemoteLogger.php | 10 ++++++++++ plugins/woocommerce/src/Internal/McStats.php | 13 +++++++++++++ 4 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 plugins/woocommerce/changelog/update-refine-error-counting diff --git a/plugins/woocommerce/changelog/update-refine-error-counting b/plugins/woocommerce/changelog/update-refine-error-counting new file mode 100644 index 00000000000..905988307d8 --- /dev/null +++ b/plugins/woocommerce/changelog/update-refine-error-counting @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Refine PHP Fatal Error Counting in MC Stat diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php index 41104fcbd65..0c0c9c14a72 100644 --- a/plugins/woocommerce/includes/class-woocommerce.php +++ b/plugins/woocommerce/includes/class-woocommerce.php @@ -27,7 +27,6 @@ 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\Internal\Logging\RemoteLogger; @@ -407,12 +406,6 @@ 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. * diff --git a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php index a15792fb29b..4e9bd7432b6 100644 --- a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php +++ b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php @@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Internal\Logging; use Automattic\WooCommerce\Utilities\FeaturesUtil; use Automattic\WooCommerce\Utilities\StringUtil; +use Automattic\WooCommerce\Internal\McStats; use WC_Rate_Limiter; use WC_Log_Levels; @@ -178,6 +179,15 @@ class RemoteLogger extends \WC_Log_Handler { return false; } + try { + // Record fatal error stats. + $mc_stats = wc_get_container()->get( McStats::class ); + $mc_stats->add( 'error', 'critical-errors' ); + $mc_stats->do_server_side_stats(); + } catch ( \Throwable $e ) { + error_log( 'Warning: Failed to record fatal error stats: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + 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; diff --git a/plugins/woocommerce/src/Internal/McStats.php b/plugins/woocommerce/src/Internal/McStats.php index 89320e1ee6a..2f05d355451 100644 --- a/plugins/woocommerce/src/Internal/McStats.php +++ b/plugins/woocommerce/src/Internal/McStats.php @@ -60,4 +60,17 @@ class McStats extends A8c_Mc_Stats { return parent::do_server_side_stat( $url ); } + + /** + * Pings the stats server for the current stats and empty the stored stats from the object + * + * @return void + */ + public function do_server_side_stats() { + if ( ! \WC_Site_Tracking::is_tracking_enabled() ) { + return; + } + + parent::do_server_side_stats(); + } } From 6bc2244ec98cd1e183ac9dfa27f18e1c1a1d7ae3 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 17 Sep 2024 22:31:55 +0800 Subject: [PATCH 28/36] Reduce dependency of remote logging on WC_Tracks (#51365) * Simplify WC_Tracks::get_blog_details() to reduce its dependencies * Add changelog * Remove store_id field * Improve the check --- .../enhance-remote-logging-reduce-dependency | 4 ++++ .../src/Internal/Logging/RemoteLogger.php | 21 +++++++------------ 2 files changed, 12 insertions(+), 13 deletions(-) create mode 100644 plugins/woocommerce/changelog/enhance-remote-logging-reduce-dependency diff --git a/plugins/woocommerce/changelog/enhance-remote-logging-reduce-dependency b/plugins/woocommerce/changelog/enhance-remote-logging-reduce-dependency new file mode 100644 index 00000000000..2a0eca872f0 --- /dev/null +++ b/plugins/woocommerce/changelog/enhance-remote-logging-reduce-dependency @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Reduce dependency of remote logging on WC_Tracks diff --git a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php index 4e9bd7432b6..2f8e3d5ab8a 100644 --- a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php +++ b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php @@ -8,6 +8,7 @@ use Automattic\WooCommerce\Utilities\StringUtil; use Automattic\WooCommerce\Internal\McStats; use WC_Rate_Limiter; use WC_Log_Levels; +use Jetpack_Options; /** * WooCommerce Remote Logger @@ -72,9 +73,16 @@ class RemoteLogger extends \WC_Log_Handler { 'php_version' => phpversion(), 'wp_version' => get_bloginfo( 'version' ), 'request_uri' => $this->sanitize_request_uri( filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ) ), + 'store_id' => get_option( \WC_Install::STORE_ID_OPTION, null ), ), ); + $blog_id = class_exists( 'Jetpack_Options' ) ? Jetpack_Options::get_option( 'id' ) : null; + + if ( ! empty( $blog_id ) && is_int( $blog_id ) ) { + $log_data['blog_id'] = $blog_id; + } + if ( isset( $context['backtrace'] ) ) { if ( is_array( $context['backtrace'] ) || is_string( $context['backtrace'] ) ) { $log_data['trace'] = $this->sanitize_trace( $context['backtrace'] ); @@ -89,19 +97,6 @@ class RemoteLogger extends \WC_Log_Handler { unset( $context['tags'] ); } - if ( class_exists( '\WC_Tracks' ) && function_exists( 'wp_get_current_user' ) ) { - $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'] ); } From 514b39eea0a122f6d8430098b4b9f264224d2837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Juh=C3=A9=20Lluveras?= Date: Tue, 17 Sep 2024 16:56:55 +0200 Subject: [PATCH 29/36] Fix a type mismatch in UpdateProducts.php (#51408) * Fix a type mismatch in UpdateProducts.php * Add changelog file * Fix a type mismatch in UpdateProducts.php (II) --- .../changelog/fix-type-mismatch-updateproducts | 5 +++++ .../src/Blocks/AIContent/UpdateProducts.php | 11 ++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 plugins/woocommerce/changelog/fix-type-mismatch-updateproducts diff --git a/plugins/woocommerce/changelog/fix-type-mismatch-updateproducts b/plugins/woocommerce/changelog/fix-type-mismatch-updateproducts new file mode 100644 index 00000000000..1af042a90a0 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-type-mismatch-updateproducts @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: Fix a type mismatch in UpdateProducts.php + + diff --git a/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php b/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php index 571cb08029e..3ad62a779bb 100644 --- a/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php +++ b/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php @@ -4,6 +4,7 @@ namespace Automattic\WooCommerce\Blocks\AIContent; use Automattic\WooCommerce\Blocks\AI\Connection; use WP_Error; + /** * Pattern Images class. * @@ -473,11 +474,11 @@ class UpdateProducts { /** * Update the product with the new content. * - * @param \WC_Product $product The product. - * @param int $product_image_id The product image ID. - * @param string $product_title The product title. - * @param string $product_description The product description. - * @param int $product_price The product price. + * @param \WC_Product $product The product. + * @param int|string|WP_Error $product_image_id The product image ID. + * @param string $product_title The product title. + * @param string $product_description The product description. + * @param int $product_price The product price. * * @return int|\WP_Error */ From ea6e7295ecf2424e9b5187c4a8d39cff21149fe0 Mon Sep 17 00:00:00 2001 From: Jason Kytros Date: Tue, 17 Sep 2024 18:13:46 +0300 Subject: [PATCH 30/36] Merge WooCommerce Brands into core (#50165) * Move WooCommerce Brands into core * Fix linting errors in brand-thumbnails.php * More lint fixes for brand-thumbnails.php * Fix lint issues in brand-description.php * Fix lint errors for brand-thumbnails-description * Lint errors: Fix taxonomy-product_brand * Lint errors: fix taxonomy-product_brand * Linting: Try adding a space between ignore command and docblock * Another try to remove the lowercase file name error * Another try removing lint error for lowercase files * Lint errors: Fix brands-a-z.php * More lint fixes for brands-a-z.php * More lint fixes for brands-a-z.php * Lint fixes: brand-description.php * Another try fixing lint errors for brand-description.php * Another try fixing lint errors for brand-description.php * More fixes for brand-description.php * Fix lint errors for Packages.php * More fixes for Packages.php * Linting fixes for Brands.php * Added docblocks for WC_Widget_Brand_Thumbnails variables * Add script to fix coding standards for files changed in branch * Run autofix script for linting * Lint fixes for class-wc-widget-brand-thumbnails.php * More lint fixes for: class-wc-widget-brand-thumbnails.php * More lint fixes for class-wc-widget-brand-thumbnails.php * Lint fixes for class-wc-widget-brand-nav.php * lint fixes: ignore docblocks * Another try to fix missing docblocks * Another try to fix missing docblocks * Another try fixing missing docblocks * Better messages for fix-branch.sh * Fix lint errors in class-wc-widget-brand-description.php * Fix linting errors in REST API and functions classes * Fix linting issues in class-wc-brands.php * More lint fixes for wc-brands.php * More lint fixes for wc-brands.php * Fix lint errors for wc-brands-coupons.php * Fix lint errors for class-wc-brands-block-templates.php * Fix linting errors for class-wc-brands-block-template-utils-duplicated.php * Fix lint errors in class-wc-admin-brands.php * More fixes in class-wc-admin-brands.php * More class-wc-admin-brands.php * More lint fixes for: class-wc-admin-brands.php * More lint fixes for class-wc-admin-brands.php * Transfer unit test * Transfer e2e test * Added specific versions to templates * Added changelog * Another try for HTML templates version * Fix lint errors in test files * More lint fixes * Fix lint warnings * Added brands to list of expected REST API fields * More lint warning fixes * More lint warning fixes * Updated unit tests to include brands * Remove whitespace * Added declare( strict_types = 1); to all PHP files * Added declare( strict_types = 1) to test file as well * Fix: There must be exactly one blank line after the file comment * Temporarily remove Brands e2e tests * Move Brands blockified templates * Remove script to fix lint errors in current branch * Try removing pull-package-deps * Bring back deps * Commit pnpm-lock.yaml * Add debug statements * More debug statements * Make regular expression more specific * Make matches more specific * Search only for PHP templates * Bring back whitespace * Remove unnecessary change * Update pnpm-lock.yaml * Change the way Brands files are included * Include all files * Prevent Brands assets from being double-enqueued * Move Brands scripts handling into core * Revert changes in the template-changes.ts script * Use strict in_array * Add scaffolding for Brands test * Add more scaffolding for Brands tests * Enhance e2e test by adding steps for creating a Brand * Move Brands test to Playwright folder * Added manifest * Fix lint errors in tests * Move Brands coupons test into core's coupons tests * Fix linting error in tests * Move all Brands initialization within the /Internal/Brands class * Rename `$merged_packages` to `$merged_plugins` * Add force disable method back * Move Brands logic outside core files * Rename admin styles * Remove brands logic from core's admin class * Roll back all changes in admin assets class * Fix linting errors * Move REST API logic to Brands main class * Introduce an option to control whether the Brands package is enabled. Prevent autoloader from loading classes already loaded by individual Packages. Fix an issue with Brands admin styles. * Bring back pnpm-lock * Add comment * Split long line into two * Review default values for remote variant assignment * Rename global functions and add polyfills for deprecated functions * Bump versions * Fix some lint errors * More lint fixes * Set woocommerce_remote_variant_assignment for Brands to be enabled for unit tests * Replace reserved word class with class_name * Another try to include Brands files in tests * Remove Brands from REST API tests * Skip Brands tests while Brands is behind a feature flag * Lint fixes * Remove empty line * Added feature flag. * Fix widgets form * Fix lint errors for brand description widget * Fix lint errors for brand description widget * Fix lint errors * Bump version * Updated tooltips for Brands coupon restrictions to match core's * Fix lint errors * More lint fixes * Add REST API v3 for Brands --------- Co-authored-by: Walther Lalk <83255+dakota@users.noreply.github.com> --- docs/docs-manifest.json | 4 +- .../core-critical-flows.md | 3 +- .../changelog/merge-brands-in-core | 4 + .../client/legacy/css/brands-admin.scss | 3 + .../woocommerce/client/legacy/css/brands.scss | 173 +++ .../js/admin/wc-brands-enhanced-select.js | 94 ++ .../includes/admin/class-wc-admin-brands.php | 792 ++++++++++++ ...brands-block-template-utils-duplicated.php | 369 ++++++ .../class-wc-brands-block-templates.php | 156 +++ .../includes/class-wc-autoloader.php | 5 + ...class-wc-brands-brand-settings-manager.php | 68 ++ .../includes/class-wc-brands-coupons.php | 189 +++ .../woocommerce/includes/class-wc-brands.php | 1070 +++++++++++++++++ ...s-wc-rest-product-brands-v2-controller.php | 40 + ...lass-wc-rest-product-brands-controller.php | 39 + .../includes/wc-brands-functions.php | 141 +++ .../class-wc-widget-brand-description.php | 130 ++ .../widgets/class-wc-widget-brand-nav.php | 531 ++++++++ .../class-wc-widget-brand-thumbnails.php | 235 ++++ plugins/woocommerce/src/Internal/Brands.php | 61 + plugins/woocommerce/src/Packages.php | 150 ++- .../templates/brands/brand-description.php | 35 + .../brands/shortcodes/brands-a-z.php | 63 + .../brands/shortcodes/single-brand.php | 38 + .../brands/taxonomy-product_brand.php | 12 + .../brands/widgets/brand-description.php | 27 + .../widgets/brand-thumbnails-description.php | 58 + .../brands/widgets/brand-thumbnails.php | 45 + .../blockified/taxonomy-product_brand.html | 42 + .../templates/taxonomy-product_brand.html | 5 + .../merchant/create-product-brand.spec.js | 181 +++ .../create-restricted-coupons.spec.js | 26 + .../admin/class-wc-admin-brands-test.php | 116 ++ 33 files changed, 4894 insertions(+), 11 deletions(-) create mode 100644 plugins/woocommerce/changelog/merge-brands-in-core create mode 100644 plugins/woocommerce/client/legacy/css/brands-admin.scss create mode 100644 plugins/woocommerce/client/legacy/css/brands.scss create mode 100644 plugins/woocommerce/client/legacy/js/admin/wc-brands-enhanced-select.js create mode 100644 plugins/woocommerce/includes/admin/class-wc-admin-brands.php create mode 100644 plugins/woocommerce/includes/blocks/class-wc-brands-block-template-utils-duplicated.php create mode 100644 plugins/woocommerce/includes/blocks/class-wc-brands-block-templates.php create mode 100644 plugins/woocommerce/includes/class-wc-brands-brand-settings-manager.php create mode 100644 plugins/woocommerce/includes/class-wc-brands-coupons.php create mode 100644 plugins/woocommerce/includes/class-wc-brands.php create mode 100644 plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php create mode 100644 plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-brands-controller.php create mode 100644 plugins/woocommerce/includes/wc-brands-functions.php create mode 100644 plugins/woocommerce/includes/widgets/class-wc-widget-brand-description.php create mode 100644 plugins/woocommerce/includes/widgets/class-wc-widget-brand-nav.php create mode 100644 plugins/woocommerce/includes/widgets/class-wc-widget-brand-thumbnails.php create mode 100644 plugins/woocommerce/src/Internal/Brands.php create mode 100644 plugins/woocommerce/templates/brands/brand-description.php create mode 100644 plugins/woocommerce/templates/brands/shortcodes/brands-a-z.php create mode 100644 plugins/woocommerce/templates/brands/shortcodes/single-brand.php create mode 100644 plugins/woocommerce/templates/brands/taxonomy-product_brand.php create mode 100644 plugins/woocommerce/templates/brands/widgets/brand-description.php create mode 100644 plugins/woocommerce/templates/brands/widgets/brand-thumbnails-description.php create mode 100644 plugins/woocommerce/templates/brands/widgets/brand-thumbnails.php create mode 100644 plugins/woocommerce/templates/templates/blockified/taxonomy-product_brand.html create mode 100644 plugins/woocommerce/templates/templates/taxonomy-product_brand.html create mode 100644 plugins/woocommerce/tests/e2e-pw/tests/merchant/create-product-brand.spec.js create mode 100644 plugins/woocommerce/tests/php/includes/admin/class-wc-admin-brands-test.php diff --git a/docs/docs-manifest.json b/docs/docs-manifest.json index 06efcba2d4a..d18a4367869 100644 --- a/docs/docs-manifest.json +++ b/docs/docs-manifest.json @@ -1229,7 +1229,7 @@ "menu_title": "Core critical flows", "tags": "reference", "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/quality-and-best-practices/core-critical-flows.md", - "hash": "472f5a240abe907fec83a8a9f88af6699f2d994aa7ae87faa1716a087baa66db", + "hash": "34109195216ebcb5b23e741391b9f355ba861777a5533d4ef1e341472cb5209e", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/quality-and-best-practices/core-critical-flows.md", "id": "e561b46694dba223c38b87613ce4907e4e14333a" }, @@ -1804,5 +1804,5 @@ "categories": [] } ], - "hash": "3cad7f812ae15abd4936a536cf56db63b0f1b549e26eeb3427fe45989647d58c" + "hash": "47dc2a7e213e1e9d83e93a85dafdf8c7b539cc5474b4166eb6398b734d150ff3" } \ No newline at end of file diff --git a/docs/quality-and-best-practices/core-critical-flows.md b/docs/quality-and-best-practices/core-critical-flows.md index d959b4d54e6..11fb8ec2466 100644 --- a/docs/quality-and-best-practices/core-critical-flows.md +++ b/docs/quality-and-best-practices/core-critical-flows.md @@ -147,13 +147,14 @@ These flows will continually evolve as the platform evolves with flows updated, ### Merchant - Settings | User Type | Flow Area | Flow Name | Test File | -| --------- | --------- | -------------------------------------- | ---------------------------------------- | +| --------- | --------- |----------------------------------------|------------------------------------------| | Merchant | Settings | Update General Settings | merchant/settings-general.spec.js | | Merchant | Settings | Add Tax Rates | merchant/settings-tax.spec.js | | Merchant | Settings | Add Shipping Zones | merchant/create-shipping-zones.spec.js | | Merchant | Settings | Add Shipping Classes | merchant/create-shipping-classes.spec.js | | Merchant | Settings | Enable local pickup for checkout block | merchant/settings-shipping.spec.js | | Merchant | Settings | Update payment settings | admin-tasks/payment.spec.js | +| Merchant | Settings | Handle Product Brands | merchant/create-product-brand.spec.js | ### Merchant - Coupons diff --git a/plugins/woocommerce/changelog/merge-brands-in-core b/plugins/woocommerce/changelog/merge-brands-in-core new file mode 100644 index 00000000000..65fd35876a3 --- /dev/null +++ b/plugins/woocommerce/changelog/merge-brands-in-core @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Introduced Product Brands. diff --git a/plugins/woocommerce/client/legacy/css/brands-admin.scss b/plugins/woocommerce/client/legacy/css/brands-admin.scss new file mode 100644 index 00000000000..5f9e47fed78 --- /dev/null +++ b/plugins/woocommerce/client/legacy/css/brands-admin.scss @@ -0,0 +1,3 @@ +table.wp-list-table .column-taxonomy-product_brand { + width: 10%; +} diff --git a/plugins/woocommerce/client/legacy/css/brands.scss b/plugins/woocommerce/client/legacy/css/brands.scss new file mode 100644 index 00000000000..060d28a0278 --- /dev/null +++ b/plugins/woocommerce/client/legacy/css/brands.scss @@ -0,0 +1,173 @@ +/* Brand description on archives */ +.tax-product_brand .brand-description { + overflow: hidden; + zoom: 1; +} +.tax-product_brand .brand-description img.brand-thumbnail { + width: 25%; + float: right; +} +.tax-product_brand .brand-description .text { + width: 72%; + float: left; +} + +/* Brand description widget */ +.widget_brand_description img { + -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ + -moz-box-sizing: border-box; /* Firefox, other Gecko */ + box-sizing: border-box; /* Opera/IE 8+ */ + width: 100%; + max-width: none; + height: auto; + margin: 0 0 1em; +} + +/* Brand thumbnails widget */ +ul.brand-thumbnails { + margin-left: 0; + margin-bottom: 0; + clear: both; + list-style: none; +} + +ul.brand-thumbnails:before { + clear: both; + content: ""; + display: table; +} + +ul.brand-thumbnails:after { + clear: both; + content: ""; + display: table; +} + +ul.brand-thumbnails li { + float: left; + margin: 0 3.8% 1em 0; + padding: 0; + position: relative; + width: 22.05%; /* 4 columns */ +} + +ul.brand-thumbnails.fluid-columns li { + width: auto; +} + +ul.brand-thumbnails:not(.fluid-columns) li.first { + clear: both; +} + +ul.brand-thumbnails:not(.fluid-columns) li.last { + margin-right: 0; +} + +ul.brand-thumbnails.columns-1 li { + width: 100%; + margin-right: 0; +} + +ul.brand-thumbnails.columns-2 li { + width: 48%; +} + +ul.brand-thumbnails.columns-3 li { + width: 30.75%; +} + +ul.brand-thumbnails.columns-5 li { + width: 16.95%; +} + +ul.brand-thumbnails.columns-6 li { + width: 13.5%; +} + +.brand-thumbnails li img { + -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ + -moz-box-sizing: border-box; /* Firefox, other Gecko */ + box-sizing: border-box; /* Opera/IE 8+ */ + width: 100%; + max-width: none; + height: auto; + margin: 0; +} + +@media screen and (max-width: 768px) { + ul.brand-thumbnails:not(.fluid-columns) li { + width: 48% !important; + } + + ul.brand-thumbnails:not(.fluid-columns) li.first { + clear: none; + } + + ul.brand-thumbnails:not(.fluid-columns) li.last { + margin-right: 3.8% + } + + ul.brand-thumbnails:not(.fluid-columns) li:nth-of-type(odd) { + clear: both; + } + + ul.brand-thumbnails:not(.fluid-columns) li:nth-of-type(even) { + margin-right: 0; + } +} + +/* Brand thumbnails description */ +.brand-thumbnails-description li { + text-align: center; +} + +.brand-thumbnails-description li .term-thumbnail img { + display: inline; +} + +.brand-thumbnails-description li .term-description { + margin-top: 1em; + text-align: left; +} + +/* A-Z Shortcode */ +#brands_a_z h3:target { + text-decoration: underline; +} +ul.brands_index { + list-style: none outside; + overflow: hidden; + zoom: 1; +} +ul.brands_index li { + float: left; + margin: 0 2px 2px 0; +} +ul.brands_index li a, ul.brands_index li span { + border: 1px solid #ccc; + padding: 6px; + line-height: 1em; + float: left; + text-decoration: none; +} +ul.brands_index li span { + border-color: #eee; + color: #ddd; +} +ul.brands_index li a:hover { + border-width: 2px; + padding: 5px; + text-decoration: none; +} +ul.brands_index li a.active { + border-width: 2px; + padding: 5px; +} +div#brands_a_z a.top { + border: 1px solid #ccc; + padding: 4px; + line-height: 1em; + float: right; + text-decoration: none; + font-size: 0.8em; +} diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-brands-enhanced-select.js b/plugins/woocommerce/client/legacy/js/admin/wc-brands-enhanced-select.js new file mode 100644 index 00000000000..270d5b8dc1c --- /dev/null +++ b/plugins/woocommerce/client/legacy/js/admin/wc-brands-enhanced-select.js @@ -0,0 +1,94 @@ +/* global wc_enhanced_select_params */ +/* global wpApiSettings */ +jQuery( function( $ ) { + + function getEnhancedSelectFormatString() { + return { + 'language': { + errorLoading: function() { + // Workaround for https://github.com/select2/select2/issues/4355 instead of i18n_ajax_error. + return wc_enhanced_select_params.i18n_searching; + }, + inputTooLong: function( args ) { + var overChars = args.input.length - args.maximum; + + if ( 1 === overChars ) { + return wc_enhanced_select_params.i18n_input_too_long_1; + } + + return wc_enhanced_select_params.i18n_input_too_long_n.replace( '%qty%', overChars ); + }, + inputTooShort: function( args ) { + var remainingChars = args.minimum - args.input.length; + + if ( 1 === remainingChars ) { + return wc_enhanced_select_params.i18n_input_too_short_1; + } + + return wc_enhanced_select_params.i18n_input_too_short_n.replace( '%qty%', remainingChars ); + }, + loadingMore: function() { + return wc_enhanced_select_params.i18n_load_more; + }, + maximumSelected: function( args ) { + if ( args.maximum === 1 ) { + return wc_enhanced_select_params.i18n_selection_too_long_1; + } + + return wc_enhanced_select_params.i18n_selection_too_long_n.replace( '%qty%', args.maximum ); + }, + noResults: function() { + return wc_enhanced_select_params.i18n_no_matches; + }, + searching: function() { + return wc_enhanced_select_params.i18n_searching; + } + } + }; + } + + try { + $( document.body ) + .on( 'wc-enhanced-select-init', function() { + // Ajax category search boxes + $( ':input.wc-brands-search' ).filter( ':not(.enhanced)' ).each( function() { + var select2_args = $.extend( { + allowClear : $( this ).data( 'allow_clear' ) ? true : false, + placeholder : $( this ).data( 'placeholder' ), + minimumInputLength: $( this ).data( 'minimum_input_length' ) ? $( this ).data( 'minimum_input_length' ) : 3, + escapeMarkup : function( m ) { + return m; + }, + ajax: { + url: wpApiSettings.root + 'wc/v3/products/brands', + dataType: 'json', + delay: 250, + headers: { + 'X-WP-Nonce': wpApiSettings.nonce + }, + data: function( params ) { + return { + hide_empty: 1, + search: params.term + }; + }, + processResults: function( data ) { + const results = data + .map( term => ({ id: term.slug, text: term.name + ' (' + term.count + ')' }) ) + return { + results + }; + }, + cache: true + } + }, getEnhancedSelectFormatString() ); + + $( this ).selectWoo( select2_args ).addClass( 'enhanced' ); + }); + }) + .trigger( 'wc-enhanced-select-init' ); + } catch( err ) { + // If select2 failed (conflict?) log the error but don't stop other scripts breaking. + window.console.log( err ); + } +}); \ No newline at end of file diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-brands.php b/plugins/woocommerce/includes/admin/class-wc-admin-brands.php new file mode 100644 index 00000000000..a5f65745625 --- /dev/null +++ b/plugins/woocommerce/includes/admin/class-wc-admin-brands.php @@ -0,0 +1,792 @@ +settings_tabs = array( + 'brands' => __( 'Brands', 'woocommerce' ), + ); + + // Hiding setting for future depreciation. Only users who have touched this settings should see it. + $setting_value = get_option( 'wc_brands_show_description' ); + if ( is_string( $setting_value ) ) { + // Add the settings fields to each tab. + $this->init_form_fields(); + add_action( 'woocommerce_get_sections_products', array( $this, 'add_settings_tab' ) ); + add_action( 'woocommerce_get_settings_products', array( $this, 'add_settings_section' ), null, 2 ); + } + + add_action( 'woocommerce_update_options_catalog', array( $this, 'save_admin_settings' ) ); + + /* 2.1 */ + add_action( 'woocommerce_update_options_products', array( $this, 'save_admin_settings' ) ); + + // Add brands filtering to the coupon creation screens. + add_action( 'woocommerce_coupon_options_usage_restriction', array( $this, 'add_coupon_brands_fields' ) ); + add_action( 'woocommerce_coupon_options_save', array( $this, 'save_coupon_brands' ) ); + + // Permalinks. + add_filter( 'pre_update_option_woocommerce_permalinks', array( $this, 'validate_product_base' ) ); + + add_action( 'current_screen', array( $this, 'add_brand_base_setting' ) ); + + // CSV Import/Export Support. + // https://github.com/woocommerce/woocommerce/wiki/Product-CSV-Importer-&-Exporter + // Import. + add_filter( 'woocommerce_csv_product_import_mapping_options', array( $this, 'add_column_to_importer_exporter' ), 10 ); + add_filter( 'woocommerce_csv_product_import_mapping_default_columns', array( $this, 'add_default_column_mapping' ), 10 ); + add_filter( 'woocommerce_product_import_inserted_product_object', array( $this, 'process_import' ), 10, 2 ); + + // Export. + add_filter( 'woocommerce_product_export_column_names', array( $this, 'add_column_to_importer_exporter' ), 10 ); + add_filter( 'woocommerce_product_export_product_default_columns', array( $this, 'add_column_to_importer_exporter' ), 10 ); + add_filter( 'woocommerce_product_export_product_column_brand_ids', array( $this, 'get_column_value_brand_ids' ), 10, 2 ); + } + + /** + * Add the settings for the new "Brands" subtab. + * + * @since 9.4.0 + * + * @param array $settings Settings. + * @param array $current_section Current section. + */ + public function add_settings_section( $settings, $current_section ) { + if ( 'brands' === $current_section ) { + $settings = $this->settings; + } + return $settings; + } + + /** + * Add a new "Brands" subtab to the "Products" tab. + * + * @since 9.4.0 + * @param array $sections Sections. + */ + public function add_settings_tab( $sections ) { + $sections = array_merge( $sections, $this->settings_tabs ); + return $sections; + } + + /** + * Display coupon filter fields relating to brands. + * + * @since 9.4.0 + * @return void + */ + public function add_coupon_brands_fields() { + global $post; + // Brands. + ?> +

+ + +

+ + settings = apply_filters( + 'woocommerce_brands_settings_fields', + array( + array( + 'name' => __( 'Brands Archives', 'woocommerce' ), + 'type' => 'title', + 'desc' => '', + 'id' => 'brands_archives', + ), + array( + 'name' => __( 'Show description', 'woocommerce' ), + 'desc' => __( 'Choose to show the brand description on the archive page. Turn this off if you intend to use the description widget instead. Please note: this is only for themes that do not show the description.', 'woocommerce' ), + 'tip' => '', + 'id' => 'wc_brands_show_description', + 'css' => '', + 'std' => 'yes', + 'type' => 'checkbox', + ), + array( + 'type' => 'sectionend', + 'id' => 'brands_archives', + ), + ) + ); + } + + /** + * Enqueue scripts. + * + * @return void + */ + public function scripts() { + $screen = get_current_screen(); + $version = Constants::get_constant( 'WC_VERSION' ); + $suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min'; + + if ( 'edit-product' === $screen->id ) { + wp_register_script( + 'wc-brands-enhanced-select', + WC()->plugin_url() . '/assets/js/admin/wc-brands-enhanced-select' . $suffix . '.js', + array( 'jquery', 'selectWoo', 'wc-enhanced-select', 'wp-api' ), + $version, + true + ); + wp_localize_script( + 'wc-brands-enhanced-select', + 'wc_brands_enhanced_select_params', + array( 'ajax_url' => get_rest_url() . 'brands/search' ) + ); + wp_enqueue_script( 'wc-brands-enhanced-select' ); + } + + if ( in_array( $screen->id, array( 'edit-product_brand' ), true ) ) { + wp_enqueue_media(); + wp_enqueue_style( 'woocommerce_admin_styles' ); + } + } + + /** + * Enqueue styles. + * + * @return void + */ + public function styles() { + $version = Constants::get_constant( 'WC_VERSION' ); + wp_enqueue_style( 'brands-admin-styles', WC()->plugin_url() . '/assets/css/brands-admin.css', array(), $version ); + } + + /** + * Admin settings function. + */ + public function admin_settings() { + woocommerce_admin_fields( $this->settings ); + } + + /** + * Save admin settings function. + */ + public function save_admin_settings() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['section'] ) && 'brands' === $_GET['section'] ) { + woocommerce_update_options( $this->settings ); + } + } + + /** + * Category thumbnails. + */ + public function add_thumbnail_field() { + global $woocommerce; + ?> +

+ +
+
+ + + +
+ +
+
+ term_id, 'thumbnail_id', true ); + if ( $thumbnail_id ) { + $image = wp_get_attachment_url( $thumbnail_id ); + } + if ( empty( $image ) ) { + $image = wc_placeholder_img_src(); + } + ?> + + + +
+
+ + + +
+ +
+ + + $brands_column ), + array_slice( $columns, -2, null, true ) + ); + } + + + /** + * Columns function. + * + * @param mixed $columns Columns. + */ + public function columns( $columns ) { + if ( empty( $columns ) ) { + return $columns; + } + + $new_columns = array(); + $new_columns['cb'] = $columns['cb']; + $new_columns['thumb'] = __( 'Image', 'woocommerce' ); + unset( $columns['cb'] ); + $columns = array_merge( $new_columns, $columns ); + return $columns; + } + + /** + * Column function. + * + * @param mixed $columns Columns. + * @param mixed $column Column. + * @param mixed $id ID. + */ + public function column( $columns, $column, $id ) { + if ( 'thumb' === $column ) { + global $woocommerce; + + $image = ''; + $thumbnail_id = get_term_meta( $id, 'thumbnail_id', true ); + + if ( $thumbnail_id ) { + $image = wp_get_attachment_url( $thumbnail_id ); + } + if ( empty( $image ) ) { + $image = wc_placeholder_img_src(); + } + + $columns .= 'Thumbnail'; + + } + return $columns; + } + + /** + * Renders either dropdown or a search field for brands depending on the threshold value of + * woocommerce_product_brand_filter_threshold filter. + */ + public function render_product_brand_filter() { + // phpcs:disable WordPress.Security.NonceVerification + $brands_count = (int) wp_count_terms( 'product_brand' ); + $current_brand_slug = wc_clean( wp_unslash( $_GET['product_brand'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + + /** + * Filter the brands threshold count. + * + * @since 9.4.0 + * + * @param int $value Threshold. + */ + if ( $brands_count <= apply_filters( 'woocommerce_product_brand_filter_threshold', 100 ) ) { + wc_product_dropdown_categories( + array( + 'pad_counts' => true, + 'show_count' => true, + 'orderby' => 'name', + 'selected' => $current_brand_slug, + 'show_option_none' => __( 'Filter by brand', 'woocommerce' ), + 'option_none_value' => '', + 'value_field' => 'slug', + 'taxonomy' => 'product_brand', + 'name' => 'product_brand', + 'class' => 'dropdown_product_brand', + ) + ); + } else { + $current_brand = $current_brand_slug ? get_term_by( 'slug', $current_brand_slug, 'product_brand' ) : ''; + $selected_option = ''; + if ( $current_brand_slug && $current_brand ) { + $selected_option = ''; + } + $placeholder = esc_attr__( 'Filter by brand', 'woocommerce' ); + ?> + + id ) { + return; + } + + add_settings_field( + 'woocommerce_product_brand_slug', + __( 'Product brand base', 'woocommerce' ), + array( $this, 'product_brand_slug_input' ), + 'permalink', + 'optional' + ); + + $this->save_permalink_settings(); + } + + /** + * Add a slug input box. + */ + public function product_brand_slug_input() { + $permalink = get_option( 'woocommerce_brand_permalink', '' ); + ?> + + 'brand_ids' ); + return array_merge( $mappings, $new_mapping ); + } + + /** + * Add brands to newly imported product. + * + * @param WC_Product $product Product being imported. + * @param array $data Raw CSV data. + */ + public function process_import( $product, $data ) { + if ( empty( $data['brand_ids'] ) ) { + return; + } + + $brand_ids = array_map( 'intval', $this->parse_brands_field( $data['brand_ids'] ) ); + + wp_set_object_terms( $product->get_id(), $brand_ids, 'product_brand' ); + } + + /** + * Parse brands field from a CSV during import. + * + * Based on WC_Product_CSV_Importer::parse_categories_field() + * + * @param string $value Field value. + * @return array + */ + public function parse_brands_field( $value ) { + + // Based on WC_Product_Importer::explode_values(). + $values = str_replace( '\\,', '::separator::', explode( ',', $value ) ); + $row_terms = array(); + foreach ( $values as $row_value ) { + $row_terms[] = trim( str_replace( '::separator::', ',', $row_value ) ); + } + + $brands = array(); + foreach ( $row_terms as $row_term ) { + $parent = null; + + // WC Core uses '>', but for some reason it's already escaped at this point. + $_terms = array_map( 'trim', explode( '>', $row_term ) ); + $total = count( $_terms ); + + foreach ( $_terms as $index => $_term ) { + $term = term_exists( $_term, 'product_brand', $parent ); + + if ( is_array( $term ) ) { + $term_id = $term['term_id']; + } else { + $term = wp_insert_term( $_term, 'product_brand', array( 'parent' => intval( $parent ) ) ); + + if ( is_wp_error( $term ) ) { + break; // We cannot continue if the term cannot be inserted. + } + + $term_id = $term['term_id']; + } + + // Only requires assign the last category. + if ( ( 1 + $index ) === $total ) { + $brands[] = $term_id; + } else { + // Store parent to be able to insert or query brands based in parent ID. + $parent = $term_id; + } + } + } + + return $brands; + } + + /** + * Get brands column value for csv export. + * + * @param string $value What will be exported. + * @param WC_Product $product Product being exported. + * @return string Brands separated by commas and child brands as "parent > child". + */ + public function get_column_value_brand_ids( $value, $product ) { + $brand_ids = wp_parse_id_list( wp_get_post_terms( $product->get_id(), 'product_brand', array( 'fields' => 'ids' ) ) ); + + if ( ! count( $brand_ids ) ) { + return ''; + } + + // Based on WC_CSV_Exporter::format_term_ids(). + $formatted_brands = array(); + foreach ( $brand_ids as $brand_id ) { + $formatted_term = array(); + $ancestor_ids = array_reverse( get_ancestors( $brand_id, 'product_brand' ) ); + + foreach ( $ancestor_ids as $ancestor_id ) { + $term = get_term( $ancestor_id, 'product_brand' ); + if ( $term && ! is_wp_error( $term ) ) { + $formatted_term[] = $term->name; + } + } + + $term = get_term( $brand_id, 'product_brand' ); + + if ( $term && ! is_wp_error( $term ) ) { + $formatted_term[] = $term->name; + } + + $formatted_brands[] = implode( ' > ', $formatted_term ); + } + + // Based on WC_CSV_Exporter::implode_values(). + $values_to_implode = array(); + foreach ( $formatted_brands as $brand ) { + $brand = (string) is_scalar( $brand ) ? $brand : ''; + $values_to_implode[] = str_replace( ',', '\\,', $brand ); + } + + return implode( ', ', $values_to_implode ); + } +} + +$GLOBALS['WC_Brands_Admin'] = new WC_Brands_Admin(); diff --git a/plugins/woocommerce/includes/blocks/class-wc-brands-block-template-utils-duplicated.php b/plugins/woocommerce/includes/blocks/class-wc-brands-block-template-utils-duplicated.php new file mode 100644 index 00000000000..19844bc9d66 --- /dev/null +++ b/plugins/woocommerce/includes/blocks/class-wc-brands-block-template-utils-duplicated.php @@ -0,0 +1,369 @@ + 'block-templates', + 'DEPRECATED_TEMPLATE_PARTS' => 'block-template-parts', + 'TEMPLATES' => 'templates', + 'TEMPLATE_PARTS' => 'parts', + ); + + /** + * WooCommerce plugin slug + * + * This is used to save templates to the DB which are stored against this value in the wp_terms table. + * + * @var string + */ + protected const PLUGIN_SLUG = 'woocommerce/woocommerce'; + + /** + * Returns an array containing the references of + * the passed blocks and their inner blocks. + * + * @param array $blocks array of blocks. + * + * @return array block references to the passed blocks and their inner blocks. + */ + public static function gutenberg_flatten_blocks( &$blocks ) { + $all_blocks = array(); + $queue = array(); + foreach ( $blocks as &$block ) { + $queue[] = &$block; + } + $queue_count = count( $queue ); + + while ( $queue_count > 0 ) { + $block = &$queue[0]; + array_shift( $queue ); + $all_blocks[] = &$block; + + if ( ! empty( $block['innerBlocks'] ) ) { + foreach ( $block['innerBlocks'] as &$inner_block ) { + $queue[] = &$inner_block; + } + } + + $queue_count = count( $queue ); + } + + return $all_blocks; + } + + /** + * Parses wp_template content and injects the current theme's + * stylesheet as a theme attribute into each wp_template_part + * + * @param string $template_content serialized wp_template content. + * + * @return string Updated wp_template content. + */ + public static function gutenberg_inject_theme_attribute_in_content( $template_content ) { + $has_updated_content = false; + $new_content = ''; + $template_blocks = parse_blocks( $template_content ); + + $blocks = self::gutenberg_flatten_blocks( $template_blocks ); + foreach ( $blocks as &$block ) { + if ( + 'core/template-part' === $block['blockName'] && + ! isset( $block['attrs']['theme'] ) + ) { + $block['attrs']['theme'] = wp_get_theme()->get_stylesheet(); + $has_updated_content = true; + } + } + + if ( $has_updated_content ) { + foreach ( $template_blocks as &$block ) { + $new_content .= serialize_block( $block ); + } + + return $new_content; + } + + return $template_content; + } + + /** + * Build a unified template object based a post Object. + * + * @param \WP_Post $post Template post. + * + * @return \WP_Block_Template|\WP_Error Template. + */ + public static function gutenberg_build_template_result_from_post( $post ) { + $terms = get_the_terms( $post, 'wp_theme' ); + + if ( is_wp_error( $terms ) ) { + return $terms; + } + + if ( ! $terms ) { + return new \WP_Error( 'template_missing_theme', __( 'No theme is defined for this template.', 'woocommerce' ) ); + } + + $theme = $terms[0]->name; + $has_theme_file = true; + + $template = new \WP_Block_Template(); + $template->wp_id = $post->ID; + $template->id = $theme . '//' . $post->post_name; + $template->theme = $theme; + $template->content = $post->post_content; + $template->slug = $post->post_name; + $template->source = 'custom'; + $template->type = $post->post_type; + $template->description = $post->post_excerpt; + $template->title = $post->post_title; + $template->status = $post->post_status; + $template->has_theme_file = $has_theme_file; + $template->is_custom = false; + $template->post_types = array(); // Don't appear in any Edit Post template selector dropdown. + + if ( 'wp_template_part' === $post->post_type ) { + $type_terms = get_the_terms( $post, 'wp_template_part_area' ); + if ( ! is_wp_error( $type_terms ) && false !== $type_terms ) { + $template->area = $type_terms[0]->name; + } + } + + // We are checking 'woocommerce' to maintain legacy templates which are saved to the DB, + // prior to updating to use the correct slug. + // More information found here: https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/5423. + if ( self::PLUGIN_SLUG === $theme || 'woocommerce' === strtolower( $theme ) ) { + $template->origin = 'plugin'; + } + + return $template; + } + + /** + * Build a unified template object based on a theme file. + * + * @param array|object $template_file Theme file. + * @param string $template_type wp_template or wp_template_part. + * + * @return \WP_Block_Template Template. + */ + public static function gutenberg_build_template_result_from_file( $template_file, $template_type ) { + $template_file = (object) $template_file; + + // If the theme has an archive-products.html template but does not have product taxonomy templates + // then we will load in the archive-product.html template from the theme to use for product taxonomies on the frontend. + $template_is_from_theme = 'theme' === $template_file->source; + $theme_name = wp_get_theme()->get( 'TextDomain' ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $template_content = file_get_contents( $template_file->path ); + $template = new \WP_Block_Template(); + $template->id = $template_is_from_theme ? $theme_name . '//' . $template_file->slug : self::PLUGIN_SLUG . '//' . $template_file->slug; + $template->theme = $template_is_from_theme ? $theme_name : self::PLUGIN_SLUG; + $template->content = self::gutenberg_inject_theme_attribute_in_content( $template_content ); + // Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909. + $template->source = $template_file->source ? $template_file->source : 'plugin'; + $template->slug = $template_file->slug; + $template->type = $template_type; + $template->title = ! empty( $template_file->title ) ? $template_file->title : self::convert_slug_to_title( $template_file->slug ); + $template->status = 'publish'; + $template->has_theme_file = true; + $template->origin = $template_file->source; + $template->is_custom = false; // Templates loaded from the filesystem aren't custom, ones that have been edited and loaded from the DB are. + $template->post_types = array(); // Don't appear in any Edit Post template selector dropdown. + $template->area = 'uncategorized'; + return $template; + } + + /** + * Build a new template object so that we can make Woo Blocks default templates available in the current theme should they not have any. + * + * @param string $template_file Block template file path. + * @param string $template_type wp_template or wp_template_part. + * @param string $template_slug Block template slug e.g. single-product. + * @param bool $template_is_from_theme If the block template file is being loaded from the current theme instead of Woo Blocks. + * + * @return object Block template object. + */ + public static function create_new_block_template_object( $template_file, $template_type, $template_slug, $template_is_from_theme = false ) { + $theme_name = wp_get_theme()->get( 'TextDomain' ); + + $new_template_item = array( + 'slug' => $template_slug, + 'id' => $template_is_from_theme ? $theme_name . '//' . $template_slug : self::PLUGIN_SLUG . '//' . $template_slug, + 'path' => $template_file, + 'type' => $template_type, + 'theme' => $template_is_from_theme ? $theme_name : self::PLUGIN_SLUG, + // Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909. + 'source' => $template_is_from_theme ? 'theme' : 'plugin', + 'title' => self::convert_slug_to_title( $template_slug ), + 'description' => '', + 'post_types' => array(), // Don't appear in any Edit Post template selector dropdown. + ); + + return (object) $new_template_item; + } + + + /** + * Converts template slugs into readable titles. + * + * @param string $template_slug The templates slug (e.g. single-product). + * @return string Human friendly title converted from the slug. + */ + public static function convert_slug_to_title( $template_slug ) { + switch ( $template_slug ) { + case 'single-product': + return __( 'Single Product', 'woocommerce' ); + case 'archive-product': + return __( 'Product Archive', 'woocommerce' ); + case 'taxonomy-product_cat': + return __( 'Product Category', 'woocommerce' ); + case 'taxonomy-product_tag': + return __( 'Product Tag', 'woocommerce' ); + default: + // Replace all hyphens and underscores with spaces. + return ucwords( preg_replace( '/[\-_]/', ' ', $template_slug ) ); + } + } + + + /** + * Gets the first matching template part within themes directories + * + * Since [Gutenberg 12.1.0](https://github.com/WordPress/gutenberg/releases/tag/v12.1.0), the conventions for + * block templates and parts directory has changed from `block-templates` and `block-templates-parts` + * to `templates` and `parts` respectively. + * + * This function traverses all possible combinations of directory paths where a template or part + * could be located and returns the first one which is readable, prioritizing the new convention + * over the deprecated one, but maintaining that one for backwards compatibility. + * + * @param string $template_slug The slug of the template (i.e. without the file extension). + * @param string $template_type Either `wp_template` or `wp_template_part`. + * + * @return string|null The matched path or `null` if no match was found. + */ + public static function get_theme_template_path( $template_slug, $template_type = 'wp_template' ) { + $template_filename = $template_slug . '.html'; + $possible_templates_dir = 'wp_template' === $template_type ? array( + self::DIRECTORY_NAMES['TEMPLATES'], + self::DIRECTORY_NAMES['DEPRECATED_TEMPLATES'], + ) : array( + self::DIRECTORY_NAMES['TEMPLATE_PARTS'], + self::DIRECTORY_NAMES['DEPRECATED_TEMPLATE_PARTS'], + ); + + // Combine the possible root directory names with either the template directory + // or the stylesheet directory for child themes. + $possible_paths = array_reduce( + $possible_templates_dir, + function ( $carry, $item ) use ( $template_filename ) { + $filepath = DIRECTORY_SEPARATOR . $item . DIRECTORY_SEPARATOR . $template_filename; + + $carry[] = get_template_directory() . $filepath; + $carry[] = get_stylesheet_directory() . $filepath; + + return $carry; + }, + array() + ); + + // Return the first matching. + foreach ( $possible_paths as $path ) { + if ( is_readable( $path ) ) { + return $path; + } + } + + return null; + } + + /** + * Check if the theme has a template. So we know if to load our own in or not. + * + * @param string $template_name name of the template file without .html extension e.g. 'single-product'. + * @return boolean + */ + public static function theme_has_template( $template_name ) { + return (bool) self::get_theme_template_path( $template_name, 'wp_template' ); + } + + /** + * Check if the theme has a template. So we know if to load our own in or not. + * + * @param string $template_name name of the template file without .html extension e.g. 'single-product'. + * @return boolean + */ + public static function theme_has_template_part( $template_name ) { + return (bool) self::get_theme_template_path( $template_name, 'wp_template_part' ); + } + + /** + * Checks to see if they are using a compatible version of WP, or if not they have a compatible version of the Gutenberg plugin installed. + * + * @return boolean + */ + public static function supports_block_templates() { + if ( + ( ! function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme() ) && + ( ! function_exists( 'gutenberg_supports_block_templates' ) || ! gutenberg_supports_block_templates() ) + ) { + return false; + } + + return true; + } + + /** + * Returns whether the blockified templates should be used or not. + * + * First, we need to make sure WordPress version is higher than 6.1 (lowest that supports Products block). + * Then, if the option is not stored on the db, we need to check if the current theme is a block one or not. + * + * @return boolean + */ + public static function should_use_blockified_product_grid_templates() { + $minimum_wp_version = '6.1'; + + if ( version_compare( $GLOBALS['wp_version'], $minimum_wp_version, '<' ) ) { + return false; + } + + $use_blockified_templates = wc_string_to_bool( get_option( Options::WC_BLOCK_USE_BLOCKIFIED_PRODUCT_GRID_BLOCK_AS_TEMPLATE ) ); + + if ( false === $use_blockified_templates ) { + return function_exists( 'wc_current_theme_is_fse_theme' ) && wc_current_theme_is_fse_theme(); + } + + return $use_blockified_templates; + } +} diff --git a/plugins/woocommerce/includes/blocks/class-wc-brands-block-templates.php b/plugins/woocommerce/includes/blocks/class-wc-brands-block-templates.php new file mode 100644 index 00000000000..efba2807519 --- /dev/null +++ b/plugins/woocommerce/includes/blocks/class-wc-brands-block-templates.php @@ -0,0 +1,156 @@ + 'taxonomy-product_brand', + 'post_type' => 'wp_template', + 'post_status' => 'publish', + 'posts_per_page' => 1, + ) + ); + + if ( count( $posts ) ) { + return $posts[0]; + } + + return null; + } + + /** + * Fixes a bug regarding taxonomies and FSE. + * Without this, the system will always load archive-product.php version instead of taxonomy_product_brand.html + * it will show a deprecation error if that happens. + * + * Triggered by woocommerce_has_block_template filter + * + * @param bool $has_template True if the template is available. + * @param string $template_name The name of the template. + * + * @return bool True if the system is checking archive-product + */ + public function has_block_template( $has_template, $template_name ) { + if ( 'archive-product' === $template_name || 'taxonomy-product_brand' === $template_name ) { + $has_template = true; + } + + return $has_template; + } + + /** + * Get the block template for Taxonomy Product Brand. First it attempts to load the last version from DB + * Otherwise it loads the file based template. + * + * @param string $template_type The post_type for the template. Normally wp_template or wp_template_part. + * + * @return WP_Block_Template The taxonomy-product_brand template. + */ + private function get_product_brands_template( $template_type ) { + $template_db = $this->get_product_brand_template_db(); + + if ( $template_db ) { + return BlockTemplateUtilsDuplicated::gutenberg_build_template_result_from_post( $template_db ); + } + + $template_path = BlockTemplateUtilsDuplicated::should_use_blockified_product_grid_templates() + ? WC()->plugin_path() . '/templates/templates/blockified/taxonomy-product_brand.html' + : WC()->plugin_path() . '/templates/templates/taxonomy-product_brand.html'; + + $template_file = BlockTemplateUtilsDuplicated::create_new_block_template_object( $template_path, $template_type, 'taxonomy-product_brand', false ); + + return BlockTemplateUtilsDuplicated::gutenberg_build_template_result_from_file( $template_file, $template_type ); + } + + /** + * Function to check if a template name is woocommerce/taxonomy-product_brand + * + * Notice depending on the version of WooCommerce this could be: + * + * woocommerce//taxonomy-product_brand + * woocommerce/woocommerce//taxonomy-product_brand + * + * @param String $id The string to check if contains the template name. + * + * @return bool True if the template is woocommerce/taxonomy-product_brand + */ + private function is_taxonomy_product_brand_template( $id ) { + return strpos( $id, 'woocommerce//taxonomy-product_brand' ) !== false; + } + + /** + * Get the block template for Taxonomy Product Brand if requested. + * Triggered by get_block_file_template action + * + * @param WP_Block_Template|null $block_template The current Block Template loaded, if any. + * @param string $id The template id normally in the format theme-slug//template-slug. + * @param string $template_type The post_type for the template. Normally wp_template or wp_template_part. + * + * @return WP_Block_Template|null The taxonomy-product_brand template. + */ + public function get_block_file_template( $block_template, $id, $template_type ) { + if ( $this->is_taxonomy_product_brand_template( $id ) && is_null( $block_template ) ) { + $block_template = $this->get_product_brands_template( $template_type ); + } + + return $block_template; + } + + /** + * Add the Block template in the template query results needed by FSE + * Triggered by get_block_templates action + * + * @param array $query_result The list of templates to render in the query. + * @param array $query The current query parameters. + * @param string $template_type The post_type for the template. Normally wp_template or wp_template_part. + * + * @return WP_Block_Template[] Array of the matched Block Templates to render. + */ + public function get_block_templates( $query_result, $query, $template_type ) { + // We don't want to run this if we are looking for template-parts. Like the header. + if ( 'wp_template' !== $template_type ) { + return $query_result; + } + + $post_id = isset( $_REQUEST['postId'] ) ? wc_clean( wp_unslash( $_REQUEST['postId'] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $slugs = $query['slug__in'] ?? array(); + + // Only add the template if asking for Product Brands. + if ( + in_array( 'taxonomy-product_brand', $slugs, true ) || + ( ! $post_id && ! count( $slugs ) ) || + ( ! count( $slugs ) && $this->is_taxonomy_product_brand_template( $post_id ) ) + ) { + $query_result[] = $this->get_product_brands_template( $template_type ); + } + + return $query_result; + } +} + +new WC_Brands_Block_Templates(); diff --git a/plugins/woocommerce/includes/class-wc-autoloader.php b/plugins/woocommerce/includes/class-wc-autoloader.php index 6f3b3f51be1..3c9c9570eee 100644 --- a/plugins/woocommerce/includes/class-wc-autoloader.php +++ b/plugins/woocommerce/includes/class-wc-autoloader.php @@ -77,6 +77,11 @@ class WC_Autoloader { return; } + // If the class is already loaded from a merged package, prevent autoloader from loading it as well. + if ( \Automattic\WooCommerce\Packages::should_load_class( $class ) ) { + return; + } + $file = $this->get_file_name_from_class( $class ); $path = ''; diff --git a/plugins/woocommerce/includes/class-wc-brands-brand-settings-manager.php b/plugins/woocommerce/includes/class-wc-brands-brand-settings-manager.php new file mode 100644 index 00000000000..5b68df7e4df --- /dev/null +++ b/plugins/woocommerce/includes/class-wc-brands-brand-settings-manager.php @@ -0,0 +1,68 @@ +get_id(); + + // Check if the brand settings are already set for this coupon. + if ( isset( self::$brand_settings[ $coupon_id ] ) ) { + return; + } + + $included_brands = get_post_meta( $coupon_id, 'product_brands', true ); + $included_brands = ! empty( $included_brands ) ? $included_brands : array(); + + $excluded_brands = get_post_meta( $coupon_id, 'exclude_product_brands', true ); + $excluded_brands = ! empty( $excluded_brands ) ? $excluded_brands : array(); + + // Store these settings in the static array. + self::$brand_settings[ $coupon_id ] = array( + 'included_brands' => $included_brands, + 'excluded_brands' => $excluded_brands, + ); + } + + /** + * Get brand settings for a coupon. + * + * @param WC_Coupon $coupon Coupon object. + * @return array Brand settings (included and excluded brands). + */ + public static function get_brand_settings_on_coupon( $coupon ) { + $coupon_id = $coupon->get_id(); + + if ( isset( self::$brand_settings[ $coupon_id ] ) ) { + return self::$brand_settings[ $coupon_id ]; + } + + // Default return value if no settings are found. + return array( + 'included_brands' => array(), + 'excluded_brands' => array(), + ); + } +} diff --git a/plugins/woocommerce/includes/class-wc-brands-coupons.php b/plugins/woocommerce/includes/class-wc-brands-coupons.php new file mode 100644 index 00000000000..065ec3e0de7 --- /dev/null +++ b/plugins/woocommerce/includes/class-wc-brands-coupons.php @@ -0,0 +1,189 @@ +set_brand_settings_on_coupon( $coupon ); + + // Only check if coupon has brand restrictions on it. + $brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon ); + + $brand_restrictions = ! empty( $brand_coupon_settings['included_brands'] ) || ! empty( $brand_coupon_settings['excluded_brands'] ); + if ( ! $brand_restrictions ) { + return $valid; + } + + $included_brands_match = false; + $excluded_brands_matches = 0; + + $items = $discounts->get_items(); + + foreach ( $items as $item ) { + $product_brands = $this->get_product_brands( $this->get_product_id( $item->product ) ); + + if ( ! empty( array_intersect( $product_brands, $brand_coupon_settings['included_brands'] ) ) ) { + $included_brands_match = true; + } + + if ( ! empty( array_intersect( $product_brands, $brand_coupon_settings['excluded_brands'] ) ) ) { + ++$excluded_brands_matches; + } + } + + // 1) Coupon has a brand requirement but no products in the cart have the brand. + if ( ! $included_brands_match && ! empty( $brand_coupon_settings['included_brands'] ) ) { + throw new Exception( WC_Coupon::E_WC_COUPON_NOT_APPLICABLE ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + // 2) All products in the cart match brand exclusion rule. + if ( count( $items ) === $excluded_brands_matches ) { + throw new Exception( self::E_WC_COUPON_EXCLUDED_BRANDS ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + // 3) For a cart discount, there is at least one product in cart that matches exclusion rule. + if ( $coupon->is_type( 'fixed_cart' ) && $excluded_brands_matches > 0 ) { + throw new Exception( self::E_WC_COUPON_EXCLUDED_BRANDS ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + return $valid; + } + + /** + * Check if a coupon is valid for a product. + * + * This allows percentage and product discounts to apply to only + * the correct products in the cart. + * + * @param bool $valid Whether the product should get the coupon's discounts. + * @param WC_Product $product WC Product Object. + * @param WC_Coupon $coupon Coupon object. + * @return bool $valid + */ + public function is_valid_for_product( $valid, $product, $coupon ) { + + if ( ! is_a( $product, 'WC_Product' ) ) { + return $valid; + } + $this->set_brand_settings_on_coupon( $coupon ); + + $product_id = $this->get_product_id( $product ); + $product_brands = $this->get_product_brands( $product_id ); + + // Check if coupon has a brand requirement and if this product has that brand attached. + $brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon ); + if ( ! empty( $brand_coupon_settings['included_brands'] ) && empty( array_intersect( $product_brands, $brand_coupon_settings['included_brands'] ) ) ) { + return false; + } + + // Check if coupon has a brand exclusion and if this product has that brand attached. + if ( ! empty( $brand_coupon_settings['excluded_brands'] ) && ! empty( array_intersect( $product_brands, $brand_coupon_settings['excluded_brands'] ) ) ) { + return false; + } + + return $valid; + } + + /** + * Display a custom error message when a cart discount coupon does not validate + * because an excluded brand was found in the cart. + * + * @param string $err The error message. + * @param string $err_code The error code. + * @return string + */ + public function brand_exclusion_error( $err, $err_code ) { + if ( self::E_WC_COUPON_EXCLUDED_BRANDS !== $err_code ) { + return $err; + } + + return __( 'Sorry, this coupon is not applicable to the brands of selected products.', 'woocommerce' ); + } + + /** + * Get a list of brands that are assigned to a specific product + * + * @param int $product_id Product id. + * @return array brands + */ + private function get_product_brands( $product_id ) { + return wp_get_post_terms( $product_id, 'product_brand', array( 'fields' => 'ids' ) ); + } + + /** + * Set brand settings as properties on coupon object. These properties are + * lists of included product brand IDs and list of excluded brand IDs. + * + * @param WC_Coupon $coupon Coupon object. + * + * @return void + */ + private function set_brand_settings_on_coupon( $coupon ) { + $brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon ); + + if ( ! empty( $brand_coupon_settings['included_brands'] ) && ! empty( $brand_coupon_settings['excluded_brands'] ) ) { + return; + } + + $included_brands = get_post_meta( $coupon->get_id(), 'product_brands', true ); + if ( empty( $included_brands ) ) { + $included_brands = array(); + } + + $excluded_brands = get_post_meta( $coupon->get_id(), 'exclude_product_brands', true ); + if ( empty( $excluded_brands ) ) { + $excluded_brands = array(); + } + + // Store these for later to avoid multiple look-ups. + WC_Brands_Brand_Settings_Manager::set_brand_settings_on_coupon( $coupon ); + } + + /** + * Returns the product (or variant) ID. + * + * @param WC_Product $product WC Product Object. + * @return int Product ID + */ + private function get_product_id( $product ) { + return $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id(); + } +} + +new WC_Brands_Coupons(); diff --git a/plugins/woocommerce/includes/class-wc-brands.php b/plugins/woocommerce/includes/class-wc-brands.php new file mode 100644 index 00000000000..71e1fa71299 --- /dev/null +++ b/plugins/woocommerce/includes/class-wc-brands.php @@ -0,0 +1,1070 @@ +template_url = apply_filters( 'woocommerce_template_url', 'woocommerce/' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + + add_action( 'plugins_loaded', array( $this, 'register_hooks' ), 2 ); + + $this->register_shortcodes(); + } + + /** + * Register our hooks + */ + public function register_hooks() { + add_action( 'woocommerce_register_taxonomy', array( __CLASS__, 'init_taxonomy' ) ); + add_action( 'widgets_init', array( $this, 'init_widgets' ) ); + + if ( ! wc_current_theme_is_fse_theme() ) { + add_filter( 'template_include', array( $this, 'template_loader' ) ); + } + + add_action( 'wp_enqueue_scripts', array( $this, 'styles' ) ); + add_action( 'wp', array( $this, 'body_class' ) ); + + add_action( 'woocommerce_product_meta_end', array( $this, 'show_brand' ) ); + add_filter( 'woocommerce_structured_data_product', array( $this, 'add_structured_data' ), 20 ); + + // duplicate product brands. + add_action( 'woocommerce_product_duplicate_before_save', array( $this, 'duplicate_store_temporary_brands' ), 10, 2 ); + add_action( 'woocommerce_new_product', array( $this, 'duplicate_add_product_brand_terms' ) ); + add_action( 'woocommerce_new_product', array( $this, 'invalidate_wc_layered_nav_counts_cache' ), 10, 0 ); + add_action( 'woocommerce_update_product', array( $this, 'invalidate_wc_layered_nav_counts_cache' ), 10, 0 ); + add_action( 'transition_post_status', array( $this, 'reset_layered_nav_counts_on_status_change' ), 10, 3 ); + + add_filter( 'post_type_link', array( $this, 'post_type_link' ), 11, 2 ); + + if ( 'yes' === get_option( 'wc_brands_show_description' ) ) { + add_action( 'woocommerce_archive_description', array( $this, 'brand_description' ) ); + } + + add_filter( 'woocommerce_product_query_tax_query', array( $this, 'update_product_query_tax_query' ), 10, 1 ); + + // REST API. + add_action( 'rest_api_init', array( $this, 'rest_api_register_routes' ) ); + add_action( 'woocommerce_rest_insert_product', array( $this, 'rest_api_maybe_set_brands' ), 10, 2 ); + add_filter( 'woocommerce_rest_prepare_product', array( $this, 'rest_api_prepare_brands_to_product' ), 10, 2 ); // WC 2.6.x. + add_filter( 'woocommerce_rest_prepare_product_object', array( $this, 'rest_api_prepare_brands_to_product' ), 10, 2 ); // WC 3.x. + add_action( 'woocommerce_rest_insert_product', array( $this, 'rest_api_add_brands_to_product' ), 10, 3 ); // WC 2.6.x. + add_action( 'woocommerce_rest_insert_product_object', array( $this, 'rest_api_add_brands_to_product' ), 10, 3 ); // WC 3.x. + add_filter( 'woocommerce_rest_product_object_query', array( $this, 'rest_api_filter_products_by_brand' ), 10, 2 ); + add_filter( 'rest_product_collection_params', array( $this, 'rest_api_product_collection_params' ), 10, 2 ); + + // Layered nav widget compatibility. + add_filter( 'woocommerce_layered_nav_term_html', array( $this, 'woocommerce_brands_update_layered_nav_link' ), 10, 4 ); + + // Filter the list of taxonomies overridden for the original term count. + add_filter( 'woocommerce_change_term_counts', array( $this, 'add_brands_to_terms' ) ); + add_action( 'woocommerce_product_set_stock_status', array( $this, 'recount_after_stock_change' ) ); + add_action( 'woocommerce_update_options_products_inventory', array( $this, 'recount_all_brands' ) ); + + // Product Editor compatibility. + add_action( 'woocommerce_layout_template_after_instantiation', array( $this, 'wc_brands_on_block_template_register' ), 10, 3 ); + } + + /** + * Add product_brand to the taxonomies overridden for the original term count. + * + * @param array $taxonomies List of taxonomies. + * + * @return array + */ + public function add_brands_to_terms( $taxonomies ) { + $taxonomies[] = 'product_brand'; + return $taxonomies; + } + + /** + * Recount the brands after the stock amount changes. + * + * @param int $product_id Product ID. + */ + public function recount_after_stock_change( $product_id ) { + if ( 'yes' !== get_option( 'woocommerce_hide_out_of_stock_items' ) || empty( $product_id ) ) { + return; + } + + $product_terms = get_the_terms( $product_id, 'product_brand' ); + + if ( $product_terms ) { + $product_brands = array(); + + foreach ( $product_terms as $term ) { + $product_brands[ $term->term_id ] = $term->parent; + } + + _wc_term_recount( $product_brands, get_taxonomy( 'product_brand' ), false, false ); + } + } + + /** + * Recount all brands. + */ + public function recount_all_brands() { + $product_brands = get_terms( + array( + 'taxonomy' => 'product_brand', + 'hide_empty' => false, + 'fields' => 'id=>parent', + ) + ); + _wc_term_recount( $product_brands, get_taxonomy( 'product_brand' ), true, false ); + } + + /** + * Update the main product fetch query to filter by selected brands. + * + * @param array $tax_query array of current taxonomy filters. + * + * @return array + */ + public function update_product_query_tax_query( array $tax_query ) { + if ( isset( $_GET['filter_product_brand'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $filter_product_brand = wc_clean( wp_unslash( $_GET['filter_product_brand'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $brands_filter = array_filter( array_map( 'absint', explode( ',', $filter_product_brand ) ) ); + + if ( $brands_filter ) { + $tax_query[] = array( + 'taxonomy' => 'product_brand', + 'terms' => $brands_filter, + 'operator' => 'IN', + ); + } + } + + return $tax_query; + } + + /** + * Filter to allow product_brand in the permalinks for products. + * + * @param string $permalink The existing permalink URL. + * @param WP_Post $post The post. + * @return string + */ + public function post_type_link( $permalink, $post ) { + // Abort if post is not a product. + if ( 'product' !== $post->post_type ) { + return $permalink; + } + + // Abort early if the placeholder rewrite tag isn't in the generated URL. + if ( false === strpos( $permalink, '%' ) ) { + return $permalink; + } + + // Get the custom taxonomy terms in use by this post. + $terms = get_the_terms( $post->ID, 'product_brand' ); + + if ( empty( $terms ) ) { + // If no terms are assigned to this post, use a string instead (can't leave the placeholder there). + $product_brand = _x( 'uncategorized', 'slug', 'woocommerce' ); + } else { + // Replace the placeholder rewrite tag with the first term's slug. + $first_term = array_shift( $terms ); + $product_brand = $first_term->slug; + } + + $find = array( + '%product_brand%', + ); + + $replace = array( + $product_brand, + ); + + $replace = array_map( 'sanitize_title', $replace ); + + $permalink = str_replace( $find, $replace, $permalink ); + + return $permalink; + } + + /** + * Adds filter for introducing CSS classes. + */ + public function body_class() { + if ( is_tax( 'product_brand' ) ) { + add_filter( 'body_class', array( $this, 'add_body_class' ) ); + } + } + + /** + * Adds classes to brand taxonomy pages. + * + * @param array $classes Classes array. + */ + public function add_body_class( $classes ) { + $classes[] = 'woocommerce'; + $classes[] = 'woocommerce-page'; + return $classes; + } + + /** + * Enqueues styles. + */ + public function styles() { + $version = Constants::get_constant( 'WC_VERSION' ); + wp_enqueue_style( 'brands-styles', WC()->plugin_url() . '/assets/css/brands.css', array(), $version ); + } + + /** + * Initializes brand taxonomy. + */ + public static function init_taxonomy() { + $shop_page_id = wc_get_page_id( 'shop' ); + + $base_slug = $shop_page_id > 0 && get_page( $shop_page_id ) ? get_page_uri( $shop_page_id ) : 'shop'; + $category_base = get_option( 'woocommerce_prepend_shop_page_to_urls' ) === 'yes' ? trailingslashit( $base_slug ) : ''; + + $slug = $category_base . __( 'brand', 'woocommerce' ); + if ( '' === $category_base ) { + $slug = get_option( 'woocommerce_brand_permalink', '' ); + } + + // Can't provide transatable string as get_option default. + if ( '' === $slug ) { + $slug = __( 'brand', 'woocommerce' ); + } + + register_taxonomy( + 'product_brand', + array( 'product' ), + /** + * Filter the brand taxonomy. + * + * @since 9.4.0 + * + * @param array $args Args. + */ + apply_filters( + 'register_taxonomy_product_brand', + array( + 'hierarchical' => true, + 'update_count_callback' => '_update_post_term_count', + 'label' => __( 'Brands', 'woocommerce' ), + 'labels' => array( + 'name' => __( 'Brands', 'woocommerce' ), + 'singular_name' => __( 'Brand', 'woocommerce' ), + 'search_items' => __( 'Search Brands', 'woocommerce' ), + 'all_items' => __( 'All Brands', 'woocommerce' ), + 'parent_item' => __( 'Parent Brand', 'woocommerce' ), + 'parent_item_colon' => __( 'Parent Brand:', 'woocommerce' ), + 'edit_item' => __( 'Edit Brand', 'woocommerce' ), + 'update_item' => __( 'Update Brand', 'woocommerce' ), + 'add_new_item' => __( 'Add New Brand', 'woocommerce' ), + 'new_item_name' => __( 'New Brand Name', 'woocommerce' ), + 'not_found' => __( 'No Brands Found', 'woocommerce' ), + 'back_to_items' => __( '← Go to Brands', 'woocommerce' ), + ), + + 'show_ui' => true, + 'show_admin_column' => true, + 'show_in_nav_menus' => true, + 'show_in_rest' => true, + 'capabilities' => array( + 'manage_terms' => 'manage_product_terms', + 'edit_terms' => 'edit_product_terms', + 'delete_terms' => 'delete_product_terms', + 'assign_terms' => 'assign_product_terms', + ), + + 'rewrite' => array( + 'slug' => $slug, + 'with_front' => false, + 'hierarchical' => true, + ), + ) + ) + ); + } + + /** + * Initializes brand widgets. + */ + public function init_widgets() { + // Include. + require_once WC()->plugin_path() . '/includes/widgets/class-wc-widget-brand-description.php'; + require_once WC()->plugin_path() . '/includes/widgets/class-wc-widget-brand-nav.php'; + require_once WC()->plugin_path() . '/includes/widgets/class-wc-widget-brand-thumbnails.php'; + + // Register. + register_widget( 'WC_Widget_Brand_Description' ); + register_widget( 'WC_Widget_Brand_Nav' ); + register_widget( 'WC_Widget_Brand_Thumbnails' ); + } + + /** + * + * Handles template usage so that we can use our own templates instead of the themes. + * + * Templates are in the 'templates' folder. woocommerce looks for theme + * overides in /theme/woocommerce/ by default + * + * For beginners, it also looks for a woocommerce.php template first. If the user adds + * this to the theme (containing a woocommerce() inside) this will be used for all + * woocommerce templates. + * + * @param string $template Template. + */ + public function template_loader( $template ) { + $find = array( 'woocommerce.php' ); + $file = ''; + + if ( is_tax( 'product_brand' ) ) { + + $term = get_queried_object(); + + $file = 'taxonomy-' . $term->taxonomy . '.php'; + $find[] = 'taxonomy-' . $term->taxonomy . '-' . $term->slug . '.php'; + $find[] = $this->template_url . 'taxonomy-' . $term->taxonomy . '-' . $term->slug . '.php'; + $find[] = $file; + $find[] = $this->template_url . $file; + + } + + if ( $file ) { + $template = locate_template( $find ); + if ( ! $template ) { + $template = WC()->plugin_path() . '/templates/brands/' . $file; + } + } + + return $template; + } + + /** + * Displays brand description. + */ + public function brand_description() { + if ( ! is_tax( 'product_brand' ) ) { + return; + } + + if ( ! get_query_var( 'term' ) ) { + return; + } + + $thumbnail = ''; + + $term = get_term_by( 'slug', get_query_var( 'term' ), 'product_brand' ); + $thumbnail = wc_get_brand_thumbnail_url( $term->term_id, 'full' ); + + wc_get_template( + 'brand-description.php', + array( + 'thumbnail' => $thumbnail, + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + } + + /** + * Displays brand. + */ + public function show_brand() { + global $post; + + if ( is_singular( 'product' ) ) { + $terms = get_the_terms( $post->ID, 'product_brand' ); + $brand_count = is_array( $terms ) ? count( $terms ) : 0; + + $taxonomy = get_taxonomy( 'product_brand' ); + $labels = $taxonomy->labels; + + /* translators: %s - Label name */ + echo wc_get_brands( $post->ID, ', ', ' ' . sprintf( _n( '%s: ', '%s: ', $brand_count, 'woocommerce' ), $labels->singular_name, $labels->name ), '' ); // phpcs:ignore WordPress.Security.EscapeOutput + } + } + + /** + * Add structured data to product page. + * + * @param array $markup Markup. + * @return array $markup + */ + public function add_structured_data( $markup ) { + global $post; + + if ( array_key_exists( 'brand', $markup ) ) { + return $markup; + } + + $brands = get_the_terms( $post->ID, 'product_brand' ); + + if ( ! empty( $brands ) && is_array( $brands ) ) { + // Can only return one brand, so pick the first. + $markup['brand'] = array( + '@type' => 'Brand', + 'name' => $brands[0]->name, + ); + } + + return $markup; + } + + /** + * Registers shortcodes. + */ + public function register_shortcodes() { + add_shortcode( 'product_brand', array( $this, 'output_product_brand' ) ); + add_shortcode( 'product_brand_thumbnails', array( $this, 'output_product_brand_thumbnails' ) ); + add_shortcode( 'product_brand_thumbnails_description', array( $this, 'output_product_brand_thumbnails_description' ) ); + add_shortcode( 'product_brand_list', array( $this, 'output_product_brand_list' ) ); + add_shortcode( 'brand_products', array( $this, 'output_brand_products' ) ); + } + + /** + * Displays product brand. + * + * @param array $atts Attributes from the shortcode. + * @return string The generated output. + */ + public function output_product_brand( $atts ) { + global $post; + + $args = shortcode_atts( + array( + 'width' => '', + 'height' => '', + 'class' => 'aligncenter', + 'post_id' => '', + ), + $atts + ); + + if ( ! $args['post_id'] && ! $post ) { + return ''; + } + + if ( ! $args['post_id'] ) { + $args['post_id'] = $post->ID; + } + + $brands = wp_get_post_terms( $args['post_id'], 'product_brand', array( 'fields' => 'ids' ) ); + + // Bail early if we don't have any brands registered. + if ( 0 === count( $brands ) ) { + return ''; + } + + ob_start(); + + foreach ( $brands as $brand ) { + $thumbnail = wc_get_brand_thumbnail_url( $brand ); + if ( empty( $thumbnail ) ) { + continue; + } + + $args['thumbnail'] = $thumbnail; + $args['term'] = get_term_by( 'id', $brand, 'product_brand' ); + + if ( $args['width'] || $args['height'] ) { + $args['width'] = ! empty( $args['width'] ) ? $args['width'] : 'auto'; + $args['height'] = ! empty( $args['height'] ) ? $args['height'] : 'auto'; + } + + wc_get_template( + 'shortcodes/single-brand.php', + $args, + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + } + + return ob_get_clean(); + } + + /** + * Displays product brand list. + * + * @param array $atts Attributes from the shortcode. + * @return string + */ + public function output_product_brand_list( $atts ) { + $args = shortcode_atts( + array( + 'show_top_links' => true, + 'show_empty' => true, + 'show_empty_brands' => false, + ), + $atts + ); + + $show_top_links = $args['show_top_links']; + $show_empty = $args['show_empty']; + $show_empty_brands = $args['show_empty_brands']; + + if ( 'false' === $show_top_links ) { + $show_top_links = false; + } + + if ( 'false' === $show_empty ) { + $show_empty = false; + } + + if ( 'false' === $show_empty_brands ) { + $show_empty_brands = false; + } + + $product_brands = array(); + //phpcs:disable + $terms = get_terms( array( 'taxonomy' => 'product_brand', 'hide_empty' => ( $show_empty_brands ? false : true ) ) ); + $alphabet = apply_filters( 'woocommerce_brands_list_alphabet', range( 'a', 'z' ) ); + $numbers = apply_filters( 'woocommerce_brands_list_numbers', '0-9' ); + + /** + * Check for empty brands and remove them from the list. + */ + if ( ! $show_empty_brands ) { + $terms = $this->remove_terms_with_empty_products( $terms ); + } + + foreach ( $terms as $term ) { + $term_letter = $this->get_brand_name_first_character( $term->name ); + + // Allow a locale to be set for ctype_alpha(). + if ( has_filter( 'woocommerce_brands_list_locale' ) ) { + setLocale( LC_CTYPE, apply_filters( 'woocommerce_brands_list_locale', 'en_US.UTF-8' ) ); + } + + if ( ctype_alpha( $term_letter ) ) { + + foreach ( $alphabet as $i ) { + if ( $i == $term_letter ) { + $product_brands[ $i ][] = $term; + break; + } + } + } else { + $product_brands[ $numbers ][] = $term; + } + } + + ob_start(); + + wc_get_template( + 'shortcodes/brands-a-z.php', + array( + 'terms' => $terms, + 'index' => array_merge( $alphabet, array( $numbers ) ), + 'product_brands' => $product_brands, + 'show_empty' => $show_empty, + 'show_top_links' => $show_top_links, + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + + return ob_get_clean(); + } + + /** + * Get the first letter of the brand name, returning lowercase and without accents. + * + * @param string $name + * + * @return string + * @since 9.4.0 + */ + private function get_brand_name_first_character( $name ) { + // Convert to lowercase and remove accents. + $clean_name = strtolower( sanitize_title( $name ) ); + // Return the first letter of the name. + return substr( $clean_name, 0, 1 ); + } + + /** + * Displays brand thumbnails. + * + * @param mixed $atts + * @return void + */ + public function output_product_brand_thumbnails( $atts ) { + $args = shortcode_atts( + array( + 'show_empty' => true, + 'columns' => 4, + 'hide_empty' => 0, + 'orderby' => 'name', + 'exclude' => '', + 'number' => '', + 'fluid_columns' => false, + ), + $atts + ); + + $exclude = array_map( 'intval', explode( ',', $args['exclude'] ) ); + $order = 'name' === $args['orderby'] ? 'asc' : 'desc'; + + if ( 'true' === $args['show_empty'] ) { + $hide_empty = false; + } else { + $hide_empty = true; + } + + $brands = get_terms( + 'product_brand', + array( + 'hide_empty' => $hide_empty, + 'orderby' => $args['orderby'], + 'exclude' => $exclude, + 'number' => $args['number'], + 'order' => $order, + ) + ); + + if ( ! $brands ) { + return; + } + + if ( $hide_empty ) { + $brands = $this->remove_terms_with_empty_products( $brands ); + } + + ob_start(); + + wc_get_template( + 'widgets/brand-thumbnails.php', + array( + 'brands' => $brands, + 'columns' => is_numeric( $args['columns'] ) ? intval( $args['columns'] ) : 4, + 'fluid_columns' => wp_validate_boolean( $args['fluid_columns'] ), + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + + return ob_get_clean(); + } + + /** + * Displays brand thumbnails description. + * + * @param mixed $atts + * @return void + */ + public function output_product_brand_thumbnails_description( $atts ) { + $args = shortcode_atts( + array( + 'show_empty' => true, + 'columns' => 1, + 'hide_empty' => 0, + 'orderby' => 'name', + 'exclude' => '', + 'number' => '', + ), + $atts + ); + + $exclude = array_map( 'intval', explode( ',', $args['exclude'] ) ); + $order = 'name' === $args['orderby'] ? 'asc' : 'desc'; + + if ( 'true' === $args['show_empty'] ) { + $hide_empty = false; + } else { + $hide_empty = true; + } + + $brands = get_terms( + 'product_brand', + array( + 'hide_empty' => $args['hide_empty'], + 'orderby' => $args['orderby'], + 'exclude' => $exclude, + 'number' => $args['number'], + 'order' => $order, + ) + ); + + if ( ! $brands ) { + return; + } + + if ( $hide_empty ) { + $brands = $this->remove_terms_with_empty_products( $brands ); + } + + ob_start(); + + wc_get_template( + 'widgets/brand-thumbnails-description.php', + array( + 'brands' => $brands, + 'columns' => $args['columns'], + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + + return ob_get_clean(); + } + + /** + * Displays brand products. + * + * @param array $atts + * @return string + */ + public function output_brand_products( $atts ) { + if ( empty( $atts['brand'] ) ) { + return ''; + } + + // Add the brand attributes and query arguments. + add_filter( 'shortcode_atts_brand_products', array( __CLASS__, 'add_brand_products_shortcode_atts' ), 10, 4 ); + add_filter( 'woocommerce_shortcode_products_query', array( __CLASS__, 'get_brand_products_query_args' ), 10, 3 ); + + $shortcode = new WC_Shortcode_Products( $atts, 'brand_products' ); + + // Remove the brand attributes and query arguments. + remove_filter( 'shortcode_atts_brand_products', array( __CLASS__, 'add_brand_products_shortcode_atts' ), 10 ); + remove_filter( 'woocommerce_shortcode_products_query', array( __CLASS__, 'get_brand_products_query_args' ), 10 ); + + return $shortcode->get_content(); + } + + /** + * Adds the taxonomy query to the WooCommerce products shortcode query arguments. + * + * @param array $query_args + * @param array $attributes + * @param string $type + * + * @return array + */ + public static function get_brand_products_query_args( $query_args, $attributes, $type ) { + if ( 'brand_products' !== $type || empty( $attributes['brand'] ) ) { + return $query_args; + } + + $query_args['tax_query'][] = array( + 'taxonomy' => 'product_brand', + 'terms' => array_map( 'sanitize_title', explode( ',', $attributes['brand'] ) ), + 'field' => 'slug', + 'operator' => 'IN', + ); + + return $query_args; + } + + /** + * Adds the "brand" attribute to the list of WooCommerce products shortcode attributes. + * + * @param array $out The output array of shortcode attributes. + * @param array $pairs The supported attributes and their defaults. + * @param array $atts The user defined shortcode attributes. + * @param string $shortcode The shortcode name. + * + * @return array The output array of shortcode attributes. + */ + public static function add_brand_products_shortcode_atts( $out, $pairs, $atts, $shortcode ) { + $out['brand'] = array_key_exists( 'brand', $atts ) ? $atts['brand'] : ''; + + return $out; + } + + /** + * Register REST API route for /products/brands. + * + * @since 9.4.0 + * + * @return void + */ + public function rest_api_register_routes() { + require_once WC()->plugin_path() . '/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php'; + require_once WC()->plugin_path() . '/includes/rest-api/Controllers/Version3/class-wc-rest-product-brands-controller.php'; + + $controllers = array( + 'WC_REST_Product_Brands_V2_Controller', + 'WC_REST_Product_Brands_Controller' + ); + + foreach ( $controllers as $controller ) { + ( new $controller() )->register_routes(); + } + } + + /** + * Maybe set brands when requesting PUT /products/. + * + * @since 9.4.0 + * + * @param WP_Post $post Post object + * @param WP_REST_Request $request Request object + * + * @return void + */ + public function rest_api_maybe_set_brands( $post, $request ) { + if ( isset( $request['brands'] ) && is_array( $request['brands'] ) ) { + $terms = array_map( 'absint', $request['brands'] ); + wp_set_object_terms( $post->ID, $terms, 'product_brand' ); + } + } + + /** + * Prepare brands in product response. + * + * @param WP_REST_Response $response The response object. + * @param WP_Post|WC_Data $post Post object or WC object. + * @version 9.4.0 + * @return WP_REST_Response + */ + public function rest_api_prepare_brands_to_product( $response, $post ) { + $post_id = is_callable( array( $post, 'get_id' ) ) ? $post->get_id() : ( ! empty( $post->ID ) ? $post->ID : null ); + + if ( empty( $response->data['brands'] ) ) { + $terms = array(); + + foreach ( wp_get_post_terms( $post_id, 'product_brand' ) as $term ) { + $terms[] = array( + 'id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + ); + } + + $response->data['brands'] = $terms; + } + + return $response; + } + + /** + * Add brands in product response. + * + * @param WC_Data $product Inserted product object. + * @param WP_REST_Request $request Request object. + * @param boolean $creating True when creating object, false when updating. + * @version 9.4.0 + */ + public function rest_api_add_brands_to_product( $product, $request, $creating = true ) { + $product_id = is_callable( array( $product, 'get_id' ) ) ? $product->get_id() : ( ! empty( $product->ID ) ? $product->ID : null ); + $params = $request->get_params(); + $brands = isset( $params['brands'] ) ? $params['brands'] : array(); + + if ( ! empty( $brands ) ) { + if ( is_array( $brands[0] ) && array_key_exists( 'id', $brands[0] ) ) { + $brands = array_map( + function ( $brand ) { + return absint( $brand['id'] ); + }, + $brands + ); + } else { + $brands = array_map( 'absint', $brands ); + } + wp_set_object_terms( $product_id, $brands, 'product_brand' ); + } + } + + /** + * Filters products by taxonomy product_brand. + * + * @param array $args Request args. + * @param WP_REST_Request $request Request data. + * @return array Request args. + * @version 9.4.0 + */ + public function rest_api_filter_products_by_brand( $args, $request ) { + if ( ! empty( $request['brand'] ) ) { + $args['tax_query'][] = array( + 'taxonomy' => 'product_brand', + 'field' => 'term_id', + 'terms' => $request['brand'], + ); + } + + return $args; + } + + /** + * Documents additional query params for collections of products. + * + * @param array $params JSON Schema-formatted collection parameters. + * @param WP_Post_Type $post_type Post type object. + * @return array JSON Schema-formatted collection parameters. + * @version 9.4.0 + */ + public function rest_api_product_collection_params( $params, $post_type ) { + $params['brand'] = array( + 'description' => __( 'Limit result set to products assigned a specific brand ID.', 'woocommerce' ), + 'type' => 'string', + 'sanitize_callback' => 'wp_parse_id_list', + 'validate_callback' => 'rest_validate_request_arg', + ); + + return $params; + } + + /** + * Injects Brands filters into layered nav links. + * + * @param string $term_html Original link html. + * @param mixed $term Term that is currently added. + * @param string $link Original layered nav item link. + * @param number $count Number of items in that filter. + * @return string Term html. + * @version 9.4.0 + */ + public function woocommerce_brands_update_layered_nav_link( $term_html, $term, $link, $count ) { + if ( empty( $_GET['filter_product_brand'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return $term_html; + } + + $filter_product_brand = wc_clean( wp_unslash( $_GET['filter_product_brand'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $current_attributes = array_map( 'intval', explode( ',', $filter_product_brand ) ); + $current_values = ! empty( $current_attributes ) ? $current_attributes : array(); + $link = add_query_arg( + array( + 'filtering' => '1', + 'filter_product_brand' => implode( ',', $current_values ), + ), + wp_specialchars_decode( $link ) + ); + $term_html = '' . esc_html( $term->name ) . ''; + $term_html .= ' ' . apply_filters( 'woocommerce_layered_nav_count', '(' . absint( $count ) . ')', $count, $term ); + return $term_html; + } + + /** + * Temporarily tag a post with meta before it is saved in order + * to allow us to be able to use the meta when the product is saved to add + * the brands when an ID has been generated. + * + * + * @param WC_Product $duplicate + * @return WC_Product $original + */ + public function duplicate_store_temporary_brands( $duplicate, $original ) { + $terms = get_the_terms( $original->get_id(), 'product_brand' ); + if ( ! is_array( $terms ) ) { + return; + } + + $ids = array(); + foreach ( $terms as $term ) { + $ids[] = $term->term_id; + } + $duplicate->add_meta_data( 'duplicate_temp_brand_ids', $ids ); + } + + /** + * After product was added check if there are temporary brands and + * add them officially and remove the temporary brands. + * + * @since 9.4.0 + * + * @param int $product_id + */ + public function duplicate_add_product_brand_terms( $product_id ) { + $product = wc_get_product( $product_id ); + // Bail if product isn't found. + if ( ! $product instanceof WC_Product ) { + return; + } + $term_ids = $product->get_meta( 'duplicate_temp_brand_ids' ); + if ( empty( $term_ids ) ) { + return; + } + $term_taxonomy_ids = wp_set_object_terms( $product_id, $term_ids, 'product_brand' ); + $product->delete_meta_data( 'duplicate_temp_brand_ids' ); + $product->save(); + } + + /** + * Remove terms with empty products. + * + * @param WP_Term[] $terms The terms array that needs to be removed of empty products. + * + * @return WP_Term[] + */ + private function remove_terms_with_empty_products( $terms ) { + return array_filter( + $terms, + function ( $term ) { + return $term->count > 0; + } + ); + } + + /** + * Invalidates the layered nav counts cache. + * + * @return void + */ + public function invalidate_wc_layered_nav_counts_cache() { + $taxonomy = 'product_brand'; + delete_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ) ); + } + + /** + * Reset Layered Nav cached counts on product status change. + * + * @param $new_status + * @param $old_status + * @param $post + * + * @return void + */ + function reset_layered_nav_counts_on_status_change( $new_status, $old_status, $post ) { + if ( $post->post_type === 'product' && $old_status !== $new_status ) { + $this->invalidate_wc_layered_nav_counts_cache(); + } + } + + /** + * Add a new block to the template. + * + * @param string $template_id Template ID. + * @param string $template_area Template area. + * @param BlockTemplateInterface $template Template instance. + */ + public function wc_brands_on_block_template_register( $template_id, $template_area, $template ) { + + if ( 'simple-product' === $template->get_id() ) { + $section = $template->get_section_by_id( 'product-catalog-section' ); + if ( $section !== null ) { + $section->add_block( + array( + 'id' => 'woocommerce-brands-select', + 'blockName' => 'woocommerce/product-taxonomy-field', + 'order' => 15, + 'attributes' => array( + 'label' => __( 'Brands', 'woocommerce-brands' ), + 'createTitle' => __( 'Create new brand', 'woocommerce-brands' ), + 'slug' => 'product_brand', + 'property' => 'brands', + ), + ) + ); + } + } + } +} + +$GLOBALS['WC_Brands'] = new WC_Brands(); diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php new file mode 100644 index 00000000000..dc66380c2cd --- /dev/null +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php @@ -0,0 +1,40 @@ +term_id, 'thumbnail_id', true ); + + if ( '' === $size || 'brand-thumb' === $size ) { + /** + * Filter the brand's thumbnail size. + * + * @since 9.4.0 + * + * @param string $size Brand's thumbnail size. + */ + $size = apply_filters( 'woocommerce_brand_thumbnail_size', 'shop_catalog' ); + } + + if ( $thumbnail_id ) { + $image_src = wp_get_attachment_image_src( $thumbnail_id, $size ); + $image_src = $image_src[0]; + $dimensions = wc_get_image_size( $size ); + $image_srcset = function_exists( 'wp_get_attachment_image_srcset' ) ? wp_get_attachment_image_srcset( $thumbnail_id, $size ) : false; + $image_sizes = function_exists( 'wp_get_attachment_image_sizes' ) ? wp_get_attachment_image_sizes( $thumbnail_id, $size ) : false; + } else { + $image_src = wc_placeholder_img_src(); + $dimensions = wc_get_image_size( $size ); + $image_srcset = false; + $image_sizes = false; + } + + // Add responsive image markup if available. + if ( $image_srcset && $image_sizes ) { + $image = '' . esc_attr( $brand->name ) . ''; + } else { + $image = '' . esc_attr( $brand->name ) . ''; + } + + return $image; +} + +/** + * Retrieves product's brands. + * + * @param int $post_id Post ID (default: 0). + * @param string $sep Seperator (default: '). + * @param string $before Before item (default: ''). + * @param string $after After item (default: ''). + * @return array List of terms + */ +function wc_get_brands( $post_id = 0, $sep = ', ', $before = '', $after = '' ) { + global $post; + + if ( ! $post_id ) { + $post_id = $post->ID; + } + + return get_the_term_list( $post_id, 'product_brand', $before, $sep, $after ); +} + +/** + * Polyfills for backwards compatibility with the WooCommerce Brands plugin. + */ + +if ( ! function_exists( 'get_brand_thumbnail_url' ) ) { + + /** + * Polyfill for get_brand_thumbnail_image. + * + * @param int $brand_id Brand ID. + * @param string $size Thumbnail image size. + * @return string + */ + function get_brand_thumbnail_url( $brand_id, $size = 'full' ) { + return wc_get_brand_thumbnail_url( $brand_id, $size ); + } +} + +if ( ! function_exists( 'get_brand_thumbnail_image' ) ) { + + /** + * Polyfill for get_brand_thumbnail_image. + * + * @param object $brand Brand term. + * @param string $size Thumbnail image size. + * @return string + */ + function get_brand_thumbnail_image( $brand, $size = '' ) { + return wc_get_brand_thumbnail_image( $brand, $size ); + } +} + +if ( ! function_exists( 'get_brands' ) ) { + + /** + * Polyfill for get_brands. + * + * @param int $post_id Post ID (default: 0). + * @param string $sep Seperator (default: '). + * @param string $before Before item (default: ''). + * @param string $after After item (default: ''). + * @return array List of terms + */ + function get_brands( $post_id = 0, $sep = ', ', $before = '', $after = '' ) { + return wc_get_brands( $post_id, $sep, $before, $after ); + } +} diff --git a/plugins/woocommerce/includes/widgets/class-wc-widget-brand-description.php b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-description.php new file mode 100644 index 00000000000..6f117274f5c --- /dev/null +++ b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-description.php @@ -0,0 +1,130 @@ +woo_widget_name = __( 'WooCommerce Brand Description', 'woocommerce' ); + $this->woo_widget_description = __( 'When viewing a brand archive, show the current brands description.', 'woocommerce' ); + $this->woo_widget_idbase = 'wc_brands_brand_description'; + $this->woo_widget_cssclass = 'widget_brand_description'; + + /* Widget settings. */ + $widget_ops = array( + 'classname' => $this->woo_widget_cssclass, + 'description' => $this->woo_widget_description, + ); + + /* Create the widget. */ + parent::__construct( $this->woo_widget_idbase, $this->woo_widget_name, $widget_ops ); + } + + /** + * Echoes the widget content. + * + * @see WP_Widget + * + * @param array $args Display arguments including 'before_title', 'after_title', + * 'before_widget', and 'after_widget'. + * @param array $instance The settings for the particular instance of the widget. + */ + public function widget( $args, $instance ) { + extract( $args ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract + + if ( ! is_tax( 'product_brand' ) ) { + return; + } + + if ( ! get_query_var( 'term' ) ) { + return; + } + + $thumbnail = ''; + $term = get_term_by( 'slug', get_query_var( 'term' ), 'product_brand' ); + + $thumbnail = wc_get_brand_thumbnail_url( $term->term_id, 'large' ); + + echo $before_widget . $before_title . $term->name . $after_title; // phpcs:ignore WordPress.Security.EscapeOutput + + wc_get_template( + 'widgets/brand-description.php', + array( + 'thumbnail' => $thumbnail, + 'brand' => $term, + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + + echo $after_widget; // phpcs:ignore WordPress.Security.EscapeOutput + } + + /** + * Updates widget instance. + * + * @see WP_Widget->update + * + * @param array $new_instance New widget instance. + * @param array $old_instance Old widget instance. + */ + public function update( $new_instance, $old_instance ) { + $instance['title'] = wp_strip_all_tags( stripslashes( $new_instance['title'] ) ); + return $instance; + } + + /** + * Outputs the settings update form. + * + * @param array $instance Current settings. + */ + public function form( $instance ) { + ?> +

+ + +

+ widget_cssclass = 'woocommerce widget_brand_nav widget_layered_nav'; + $this->widget_description = __( 'Shows brands in a widget which lets you narrow down the list of products when viewing products.', 'woocommerce' ); + $this->widget_id = 'woocommerce_brand_nav'; + $this->widget_name = __( 'WooCommerce Brand Layered Nav', 'woocommerce' ); + + add_filter( 'woocommerce_product_subcategories_args', array( $this, 'filter_out_cats' ) ); + + /* Create the widget. */ + parent::__construct(); + } + + /** + * Filter out all categories and not display them + * + * @param array $cat_args Category arguments. + */ + public function filter_out_cats( $cat_args ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! empty( $_GET['filter_product_brand'] ) ) { + return array( 'taxonomy' => '' ); + } + + return $cat_args; + } + + /** + * Return the currently viewed taxonomy name. + * + * @return string + */ + protected function get_current_taxonomy() { + return is_tax() ? get_queried_object()->taxonomy : ''; + } + + /** + * Return the currently viewed term ID. + * + * @return int + */ + protected function get_current_term_id() { + return absint( is_tax() ? get_queried_object()->term_id : 0 ); + } + + /** + * Return the currently viewed term slug. + * + * @return int + */ + protected function get_current_term_slug() { + return absint( is_tax() ? get_queried_object()->slug : 0 ); + } + + /** + * Widget function. + * + * @see WP_Widget + * + * @param array $args Arguments. + * @param array $instance Widget instance. + * @return void + */ + public function widget( $args, $instance ) { + $attribute_array = array(); + $attribute_taxonomies = wc_get_attribute_taxonomies(); + + if ( ! empty( $attribute_taxonomies ) ) { + foreach ( $attribute_taxonomies as $tax ) { + if ( taxonomy_exists( wc_attribute_taxonomy_name( $tax->attribute_name ) ) ) { + $attribute_array[ $tax->attribute_name ] = $tax->attribute_name; + } + } + } + + if ( ! is_post_type_archive( 'product' ) && ! is_tax( array_merge( is_array( $attribute_array ) ? $attribute_array : array(), array( 'product_cat', 'product_tag' ) ) ) ) { + return; + } + + $_chosen_attributes = WC_Query::get_layered_nav_chosen_attributes(); + + $current_term = $attribute_array && is_tax( $attribute_array ) ? get_queried_object()->term_id : ''; + $current_tax = $attribute_array && is_tax( $attribute_array ) ? get_queried_object()->taxonomy : ''; + + /** + * Filter the widget's title. + * + * @since 9.4.0 + * + * @param string $title Widget title + * @param array $instance The settings for the particular instance of the widget. + * @param string $woo_widget_idbase The widget's id base. + */ + $title = apply_filters( 'widget_title', $instance['title'], $instance, $this->id_base ); + $taxonomy = 'product_brand'; + $display_type = isset( $instance['display_type'] ) ? $instance['display_type'] : 'list'; + + if ( ! taxonomy_exists( $taxonomy ) ) { + return; + } + + // Get only parent terms. Methods will recursively retrieve children. + $terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'hide_empty' => true, + 'parent' => 0, + ) + ); + + if ( empty( $terms ) ) { + return; + } + + ob_start(); + + $this->widget_start( $args, $instance ); + + if ( 'dropdown' === $display_type ) { + $found = $this->layered_nav_dropdown( $terms, $taxonomy ); + } else { + $found = $this->layered_nav_list( $terms, $taxonomy ); + } + + $this->widget_end( $args ); + + // Force found when option is selected - do not force found on taxonomy attributes. + if ( ! is_tax() && is_array( $_chosen_attributes ) && array_key_exists( $taxonomy, $_chosen_attributes ) ) { + $found = true; + } + + if ( ! $found ) { + ob_end_clean(); + } else { + echo ob_get_clean(); // phpcs:ignore WordPress.Security.EscapeOutput + } + } + + /** + * Update function. + * + * @see WP_Widget->update + * + * @param array $new_instance The new settings for the particular instance of the widget. + * @param array $old_instance The old settings for the particular instance of the widget. + * @return array + */ + public function update( $new_instance, $old_instance ) { + global $woocommerce; + + if ( empty( $new_instance['title'] ) ) { + $new_instance['title'] = __( 'Brands', 'woocommerce' ); + } + + $instance['title'] = wp_strip_all_tags( stripslashes( $new_instance['title'] ) ); + $instance['display_type'] = stripslashes( $new_instance['display_type'] ); + + return $instance; + } + + /** + * Form function. + * + * @see WP_Widget->form + * + * @param array $instance Widget instance. + * @return void + */ + public function form( $instance ) { + global $woocommerce; + + if ( ! isset( $instance['display_type'] ) ) { + $instance['display_type'] = 'list'; + } + ?> +

+ +

+ +

+

+ $data ) { + if ( $name === $taxonomy ) { + continue; + } + $filter_name = sanitize_title( str_replace( 'pa_', '', $name ) ); + if ( ! empty( $data['terms'] ) ) { + $link = add_query_arg( 'filter_' . $filter_name, implode( ',', $data['terms'] ), $link ); + } + if ( 'or' === $data['query_type'] ) { + $link = add_query_arg( 'query_type_' . $filter_name, 'or', $link ); + } + } + } + + // phpcs:enable WordPress.Security.NonceVerification.Recommended + return esc_url( $link ); + } + + /** + * Gets the currently selected attributes + * + * @return array + */ + public function get_chosen_attributes() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! empty( $_GET['filter_product_brand'] ) ) { + $filter_product_brand = wc_clean( wp_unslash( $_GET['filter_product_brand'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return array_map( 'intval', explode( ',', $filter_product_brand ) ); + } + + return array(); + } + + /** + * Show dropdown layered nav. + * + * @param array $terms Terms. + * @param string $taxonomy Taxonomy. + * @param int $depth Depth. + * @return bool Will nav display? + */ + protected function layered_nav_dropdown( $terms, $taxonomy, $depth = 0 ) { + $found = false; + + if ( $taxonomy !== $this->get_current_taxonomy() ) { + $term_counts = $this->get_filtered_term_product_counts( wp_list_pluck( $terms, 'term_id' ), $taxonomy, 'or' ); + $_chosen_attributes = $this->get_chosen_attributes(); + + if ( 0 === $depth ) { + echo ''; + + wc_enqueue_js( + " + jQuery( '.wc-brand-dropdown-layered-nav-" . esc_js( $taxonomy ) . "' ).change( function() { + var slug = jQuery( this ).val(); + location.href = '" . preg_replace( '%\/page\/[0-9]+%', '', str_replace( array( '&', '%2C' ), array( '&', ',' ), esc_js( add_query_arg( 'filtering', '1', $link ) ) ) ) . '&filter_' . esc_js( $taxonomy ) . "=' + jQuery( '.wc-brand-dropdown-layered-nav-" . esc_js( $taxonomy ) . "' ).val(); + }); + " + ); + } + } + + return $found; + } + + /** + * Show list based layered nav. + * + * @param array $terms Terms. + * @param string $taxonomy Taxonomy. + * @param int $depth Depth. + * @return bool Will nav display? + */ + protected function layered_nav_list( $terms, $taxonomy, $depth = 0 ) { + // List display. + echo '
    '; + + $term_counts = $this->get_filtered_term_product_counts( wp_list_pluck( $terms, 'term_id' ), $taxonomy, 'or' ); + $_chosen_attributes = $this->get_chosen_attributes(); + $current_values = ! empty( $_chosen_attributes ) ? $_chosen_attributes : array(); + $found = false; + + $filter_name = 'filter_' . $taxonomy; + + foreach ( $terms as $term ) { + $option_is_set = in_array( $term->term_id, $current_values, true ); + $count = isset( $term_counts[ $term->term_id ] ) ? $term_counts[ $term->term_id ] : 0; + + // skip the term for the current archive. + if ( $this->get_current_term_id() === $term->term_id ) { + continue; + } + + // Only show options with count > 0. + if ( 0 < $count ) { + $found = true; + } elseif ( 0 === $count && ! $option_is_set ) { + continue; + } + + $current_filter = isset( $_GET[ $filter_name ] ) ? explode( ',', wc_clean( wp_unslash( $_GET[ $filter_name ] ) ) ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $current_filter = array_map( 'intval', $current_filter ); + + if ( ! in_array( $term->term_id, $current_filter, true ) ) { + $current_filter[] = $term->term_id; + } + + $link = $this->get_page_base_url( $taxonomy ); + + // Add current filters to URL. + foreach ( $current_filter as $key => $value ) { + // Exclude query arg for current term archive term. + if ( $value === $this->get_current_term_id() ) { + unset( $current_filter[ $key ] ); + } + + // Exclude self so filter can be unset on click. + if ( $option_is_set && $value === $term->term_id ) { + unset( $current_filter[ $key ] ); + } + } + + if ( ! empty( $current_filter ) ) { + $link = add_query_arg( + array( + 'filtering' => '1', + $filter_name => implode( ',', $current_filter ), + ), + $link + ); + } + + echo '
  • '; + + echo ( $count > 0 || $option_is_set ) ? '' : ''; // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + + echo esc_html( $term->name ); + + echo ( $count > 0 || $option_is_set ) ? ' ' : ' '; + + echo wp_kses_post( apply_filters( 'woocommerce_layered_nav_count', '(' . absint( $count ) . ')', $count, $term ) );// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + + $child_terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'hide_empty' => true, + 'parent' => $term->term_id, + ) + ); + + if ( ! empty( $child_terms ) ) { + $found |= $this->layered_nav_list( $child_terms, $taxonomy, $depth + 1 ); + } + + echo '
  • '; + } + + echo '
'; + + return $found; + } + + /** + * Count products within certain terms, taking the main WP query into consideration. + * + * @param array $term_ids Term IDs. + * @param string $taxonomy Taxonomy. + * @param string $query_type Query type. + * @return array + */ + protected function get_filtered_term_product_counts( $term_ids, $taxonomy, $query_type = 'and' ) { + global $wpdb; + + $tax_query = WC_Query::get_main_tax_query(); + $meta_query = WC_Query::get_main_meta_query(); + + if ( 'or' === $query_type ) { + foreach ( $tax_query as $key => $query ) { + if ( is_array( $query ) && $taxonomy === $query['taxonomy'] ) { + unset( $tax_query[ $key ] ); + } + } + } + + $meta_query = new WP_Meta_Query( $meta_query ); + $tax_query = new WP_Tax_Query( $tax_query ); + $meta_query_sql = $meta_query->get_sql( 'post', $wpdb->posts, 'ID' ); + $tax_query_sql = $tax_query->get_sql( $wpdb->posts, 'ID' ); + + // Generate query. + $query = array(); + $query['select'] = "SELECT COUNT( DISTINCT {$wpdb->posts}.ID ) as term_count, terms.term_id as term_count_id"; + $query['from'] = "FROM {$wpdb->posts}"; + $query['join'] = " + INNER JOIN {$wpdb->term_relationships} AS term_relationships ON {$wpdb->posts}.ID = term_relationships.object_id + INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id ) + INNER JOIN {$wpdb->terms} AS terms USING( term_id ) + " . $tax_query_sql['join'] . $meta_query_sql['join']; + $query['where'] = " + WHERE {$wpdb->posts}.post_type IN ( 'product' ) + AND {$wpdb->posts}.post_status = 'publish' + " . $tax_query_sql['where'] . $meta_query_sql['where'] . ' + AND terms.term_id IN (' . implode( ',', array_map( 'absint', $term_ids ) ) . ') + '; + $query['group_by'] = 'GROUP BY terms.term_id'; + $query = apply_filters( 'woocommerce_get_filtered_term_product_counts_query', $query ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + $query = implode( ' ', $query ); + + // We have a query - let's see if cached results of this query already exist. + $query_hash = md5( $query ); + + $cache = apply_filters( 'woocommerce_layered_nav_count_maybe_cache', true ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + if ( true === $cache ) { + $cached_counts = (array) get_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ) ); + } else { + $cached_counts = array(); + } + + if ( ! isset( $cached_counts[ $query_hash ] ) ) { + $results = $wpdb->get_results( $query, ARRAY_A ); // @codingStandardsIgnoreLine + $counts = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) ); + $cached_counts[ $query_hash ] = $counts; + if ( true === $cache ) { + set_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ), $cached_counts, HOUR_IN_SECONDS ); + } + } + + return array_map( 'absint', (array) $cached_counts[ $query_hash ] ); + } +} diff --git a/plugins/woocommerce/includes/widgets/class-wc-widget-brand-thumbnails.php b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-thumbnails.php new file mode 100644 index 00000000000..fd6a07e38f8 --- /dev/null +++ b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-thumbnails.php @@ -0,0 +1,235 @@ +woo_widget_name = __( 'WooCommerce Brand Thumbnails', 'woocommerce' ); + $this->woo_widget_description = __( 'Show a grid of brand thumbnails.', 'woocommerce' ); + $this->woo_widget_idbase = 'wc_brands_brand_thumbnails'; + $this->woo_widget_cssclass = 'widget_brand_thumbnails'; + + /* Widget settings. */ + $widget_ops = array( + 'classname' => $this->woo_widget_cssclass, + 'description' => $this->woo_widget_description, + ); + + /* Create the widget. */ + parent::__construct( $this->woo_widget_idbase, $this->woo_widget_name, $widget_ops ); + } + + /** + * Echoes the widget content. + * + * @see WP_Widget + * + * @param array $args Display arguments including 'before_title', 'after_title', + * 'before_widget', and 'after_widget'. + * @param array $instance The settings for the particular instance of the widget. + */ + public function widget( $args, $instance ) { + $instance = wp_parse_args( + $instance, + array( + 'title' => '', + 'columns' => 1, + 'exclude' => '', + 'orderby' => 'name', + 'hide_empty' => 0, + 'number' => '', + ) + ); + + $exclude = array_map( 'intval', explode( ',', $instance['exclude'] ) ); + $order = 'name' === $instance['orderby'] ? 'asc' : 'desc'; + + $brands = get_terms( + array( + 'taxonomy' => 'product_brand', + 'hide_empty' => $instance['hide_empty'], + 'orderby' => $instance['orderby'], + 'exclude' => $exclude, + 'number' => $instance['number'], + 'order' => $order, + ) + ); + + if ( ! $brands ) { + return; + } + + /** + * Filter the widget's title. + * + * @since 9.4.0 + * + * @param string $title Widget title + * @param array $instance The settings for the particular instance of the widget. + * @param string $woo_widget_idbase The widget's id base. + */ + $title = apply_filters( 'widget_title', $instance['title'], $instance, $this->woo_widget_idbase ); + + echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput + if ( '' !== $title ) { + echo $args['before_title'] . $title . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput + } + + wc_get_template( + 'widgets/brand-thumbnails.php', + array( + 'brands' => $brands, + 'columns' => (int) $instance['columns'], + 'fluid_columns' => ! empty( $instance['fluid_columns'] ) ? true : false, + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + + echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput + } + + /** + * Update widget instance. + * + * @param array $new_instance The new settings for the particular instance of the widget. + * @param array $old_instance The old settings for the particular instance of the widget. + * + * @see WP_Widget->update + */ + public function update( $new_instance, $old_instance ) { + $instance['title'] = wp_strip_all_tags( stripslashes( $new_instance['title'] ) ); + $instance['columns'] = wp_strip_all_tags( stripslashes( $new_instance['columns'] ) ); + $instance['fluid_columns'] = ! empty( $new_instance['fluid_columns'] ) ? true : false; + $instance['orderby'] = wp_strip_all_tags( stripslashes( $new_instance['orderby'] ) ); + $instance['exclude'] = wp_strip_all_tags( stripslashes( $new_instance['exclude'] ) ); + $instance['hide_empty'] = wp_strip_all_tags( stripslashes( (string) $new_instance['hide_empty'] ) ); + $instance['number'] = wp_strip_all_tags( stripslashes( $new_instance['number'] ) ); + + if ( ! $instance['columns'] ) { + $instance['columns'] = 1; + } + + if ( ! $instance['orderby'] ) { + $instance['orderby'] = 'name'; + } + + if ( ! $instance['exclude'] ) { + $instance['exclude'] = ''; + } + + if ( ! $instance['hide_empty'] ) { + $instance['hide_empty'] = 0; + } + + if ( ! $instance['number'] ) { + $instance['number'] = ''; + } + + return $instance; + } + + /** + * Outputs the settings update form. + * + * @param array $instance Current settings. + */ + public function form( $instance ) { + if ( ! isset( $instance['hide_empty'] ) ) { + $instance['hide_empty'] = 0; + } + + if ( ! isset( $instance['orderby'] ) ) { + $instance['orderby'] = 'name'; + } + + if ( empty( $instance['fluid_columns'] ) ) { + $instance['fluid_columns'] = false; + } + + ?> +

+ + +

+ +

+ + +

+ +

+ + id="get_field_id( 'fluid_columns' ) ); ?>" name="get_field_name( 'fluid_columns' ) ); ?>" /> +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ '\\Automattic\\WooCommerce\\Admin\\Composer\\Package', + 'woocommerce-gutenberg-products-block' => '\\Automattic\\WooCommerce\\Blocks\\Package', + ); + + /** + * Similar to $base_packages, but + * the packages included in this array can be deactivated via the 'woocommerce_merged_packages' filter. + * * @var array Key is the package name/directory, value is the main package class which handles init. */ protected static $merged_packages = array( - 'woocommerce-admin' => '\\Automattic\\WooCommerce\\Admin\\Composer\\Package', - 'woocommerce-gutenberg-products-block' => '\\Automattic\\WooCommerce\\Blocks\\Package', + 'woocommerce-brands' => '\\Automattic\\WooCommerce\\Internal\\Brands', ); + /** * Init the package loader. * * @since 3.7.0 */ public static function init() { - add_action( 'plugins_loaded', array( __CLASS__, 'on_init' ) ); + add_action( 'plugins_loaded', array( __CLASS__, 'on_init' ), 0 ); + + // Prevent plugins already merged into WooCommerce core from getting activated as standalone plugins. + add_action( 'activate_plugin', array( __CLASS__, 'deactivate_merged_plugins' ) ); + + // Display a notice in the Plugins tab next to plugins already merged into WooCommerce core. + add_filter( 'all_plugins', array( __CLASS__, 'mark_merged_plugins_as_pending_update' ), 10, 1 ); + add_action( 'after_plugin_row', array( __CLASS__, 'display_notice_for_merged_plugins' ), 10, 1 ); } /** @@ -74,6 +94,61 @@ class Packages { return file_exists( dirname( __DIR__ ) . '/packages/' . $package ); } + /** + * Checks a package exists by looking for it's directory. + * + * @param string $class_name Class name. + * @return boolean + */ + public static function should_load_class( $class_name ) { + + foreach ( self::$merged_packages as $merged_package_name => $merged_package_class ) { + if ( str_replace( 'woocommerce-', 'wc_', $merged_package_name ) === $class_name ) { + return true; + } + } + + return false; + } + + /** + * Gets all merged, enabled packages. + * + * @return array + */ + protected static function get_enabled_packages() { + $enabled_packages = array(); + + foreach ( self::$merged_packages as $merged_package_name => $package_class ) { + + // For gradual rollouts, ensure that a package is enabled for user's remote variant number. + $experimental_package_enabled = method_exists( $package_class, 'is_enabled' ) ? + call_user_func( array( $package_class, 'is_enabled' ) ) : + false; + + if ( ! $experimental_package_enabled ) { + continue; + } + + $option = 'wc_feature_' . str_replace( '-', '_', $merged_package_name ) . '_enabled'; + if ( 'yes' === get_option( $option, 'no' ) ) { + $enabled_packages[ $merged_package_name ] = $package_class; + } + } + + return array_merge( $enabled_packages, self::$base_packages ); + } + + /** + * Checks if a package is enabled. + * + * @param string $package Package name. + * @return boolean + */ + public static function is_package_enabled( $package ) { + return array_key_exists( $package, self::get_enabled_packages() ); + } + /** * Deactivates merged feature plugins. * @@ -93,7 +168,8 @@ class Packages { // Deactivate the plugin if possible so that there are no conflicts. foreach ( $active_plugins as $active_plugin_path ) { $plugin_file = basename( plugin_basename( $active_plugin_path ), '.php' ); - if ( ! isset( self::$merged_packages[ $plugin_file ] ) ) { + + if ( ! self::is_package_enabled( $plugin_file ) ) { continue; } @@ -107,7 +183,7 @@ class Packages { function() use ( $plugin_data ) { echo '

'; printf( - /* translators: %s: is referring to the plugin's name. */ + /* translators: %s: is referring to the plugin's name. */ esc_html__( 'The %1$s plugin has been deactivated as the latest improvements are now included with the %2$s plugin.', 'woocommerce' ), '' . esc_html( $plugin_data['Name'] ) . '', 'WooCommerce' @@ -118,13 +194,71 @@ class Packages { } } + /** + * Prevent plugins already merged into WooCommerce core from getting activated as standalone plugins. + * + * @param string $plugin Plugin name. + */ + public static function deactivate_merged_plugins( $plugin ) { + $plugin_dir = basename( dirname( $plugin ) ); + + if ( self::is_package_enabled( $plugin_dir ) ) { + $plugins_url = esc_url( admin_url( 'plugins.php' ) ); + wp_die( + esc_html__( 'This plugin cannot be activated because its functionality is now included in WooCommerce core.', 'woocommerce' ), + esc_html__( 'Plugin Activation Error', 'woocommerce' ), + array( + 'link_url' => esc_url( $plugins_url ), + 'link_text' => esc_html__( 'Return to the Plugins page', 'woocommerce' ), + ), + ); + } + } + + /** + * Mark merged plugins as pending update. + * This is required for correctly displaying maintenance notices. + * + * @param array $plugins Plugins list. + */ + public static function mark_merged_plugins_as_pending_update( $plugins ) { + foreach ( $plugins as $plugin_name => $plugin_data ) { + $plugin_dir = basename( dirname( $plugin_name ) ); + if ( self::is_package_enabled( $plugin_dir ) ) { + // Necessary to properly display notice within row. + $plugins[ $plugin_name ]['update'] = 1; + } + } + return $plugins; + } + + /** + * Displays a maintenance notice next to merged plugins, to inform users + * that the plugin functionality is now offered by WooCommerce core. + * + * Requires 'mark_merged_plugins_as_pending_update' to properly display this notice. + * + * @param string $plugin_file Plugin file. + */ + public static function display_notice_for_merged_plugins( $plugin_file ) { + global $wp_list_table; + + $plugin_dir = basename( dirname( $plugin_file ) ); + $columns_count = $wp_list_table->get_column_count(); + $notice = __( 'This plugin can no longer be activated because its functionality is now included in WooCommerce. It is recommended to delete it.', 'woocommerce' ); + + if ( self::is_package_enabled( $plugin_dir ) ) { + echo '

' . wp_kses_post( $notice ) . '

'; + } + } + /** * Loads packages after plugins_loaded hook. * * Each package should include an init file which loads the package so it can be used by core. */ protected static function initialize_packages() { - foreach ( self::$merged_packages as $package_name => $package_class ) { + foreach ( self::get_enabled_packages() as $package_name => $package_class ) { call_user_func( array( $package_class, 'init' ) ); } @@ -172,7 +306,7 @@ class Packages { } add_action( 'admin_notices', - function() use ( $package ) { + function () use ( $package ) { ?>

diff --git a/plugins/woocommerce/templates/brands/brand-description.php b/plugins/woocommerce/templates/brands/brand-description.php new file mode 100644 index 00000000000..a72a251a3f6 --- /dev/null +++ b/plugins/woocommerce/templates/brands/brand-description.php @@ -0,0 +1,35 @@ + +

+ + + + Thumbnail + + + +
+ + + +
+ +
diff --git a/plugins/woocommerce/templates/brands/shortcodes/brands-a-z.php b/plugins/woocommerce/templates/brands/shortcodes/brands-a-z.php new file mode 100644 index 00000000000..ef2d9042a5f --- /dev/null +++ b/plugins/woocommerce/templates/brands/shortcodes/brands-a-z.php @@ -0,0 +1,63 @@ + +
+ + + + + +

+ +
    + %s', + esc_url( get_term_link( $brand->slug, 'product_brand' ) ), + esc_html( $brand->name ) + ); + } + ?> +
+ + + + + + + +
diff --git a/plugins/woocommerce/templates/brands/shortcodes/single-brand.php b/plugins/woocommerce/templates/brands/shortcodes/single-brand.php new file mode 100644 index 00000000000..556ae2055e9 --- /dev/null +++ b/plugins/woocommerce/templates/brands/shortcodes/single-brand.php @@ -0,0 +1,38 @@ + + + <?php echo esc_attr( $term->name ); ?> + diff --git a/plugins/woocommerce/templates/brands/taxonomy-product_brand.php b/plugins/woocommerce/templates/brands/taxonomy-product_brand.php new file mode 100644 index 00000000000..56898bf0cb3 --- /dev/null +++ b/plugins/woocommerce/templates/brands/taxonomy-product_brand.php @@ -0,0 +1,12 @@ + +
    + + $brand ) : + + /** + * Filter the brand's thumbnail size. + * + * @since 9.4.0 + * @param string $size Defaults to 'shop_catalog' + */ + $thumbnail = wc_get_brand_thumbnail_url( $brand->term_id, apply_filters( 'woocommerce_brand_thumbnail_size', 'shop_catalog' ) ); + + if ( ! $thumbnail ) { + $thumbnail = wc_placeholder_img_src(); + } + + $class = ''; + + if ( 0 === $index || 0 === $index % $columns ) { + $class = 'first'; + } elseif ( 0 === ( $index + 1 ) % $columns ) { + $class = 'last'; + } + + $width = floor( ( ( 100 - ( ( $columns - 1 ) * 2 ) ) / $columns ) * 100 ) / 100; + ?> +
  • + + <?php echo esc_attr( $brand->name ); ?> + +
    + description ) ) ); ?> +
    +
  • + + + +
diff --git a/plugins/woocommerce/templates/brands/widgets/brand-thumbnails.php b/plugins/woocommerce/templates/brands/widgets/brand-thumbnails.php new file mode 100644 index 00000000000..bbfdf43f236 --- /dev/null +++ b/plugins/woocommerce/templates/brands/widgets/brand-thumbnails.php @@ -0,0 +1,45 @@ + +
    + + $brand ) : + $class = ''; + if ( 0 === $index || 0 === $index % $columns ) { + $class = 'first'; + } elseif ( 0 === ( $index + 1 ) % $columns ) { + $class = 'last'; + } + ?> + +
  • + + + +
  • + + + +
diff --git a/plugins/woocommerce/templates/templates/blockified/taxonomy-product_brand.html b/plugins/woocommerce/templates/templates/blockified/taxonomy-product_brand.html new file mode 100644 index 00000000000..aa23a7d2ccc --- /dev/null +++ b/plugins/woocommerce/templates/templates/blockified/taxonomy-product_brand.html @@ -0,0 +1,42 @@ + + + +
+ + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + +
+ +
+ + + diff --git a/plugins/woocommerce/templates/templates/taxonomy-product_brand.html b/plugins/woocommerce/templates/templates/taxonomy-product_brand.html new file mode 100644 index 00000000000..4cf01077d40 --- /dev/null +++ b/plugins/woocommerce/templates/templates/taxonomy-product_brand.html @@ -0,0 +1,5 @@ + + +
+ + diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-product-brand.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-product-brand.spec.js new file mode 100644 index 00000000000..2288819aa86 --- /dev/null +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-product-brand.spec.js @@ -0,0 +1,181 @@ +const { test, expect } = require( '@playwright/test' ); + +test.use( { storageState: process.env.ADMINSTATE } ); + +test.skip( 'Merchant can add brands', async ( { page } ) => { + /** + * Go to the Brands page. + * + * This will visit the Products page first, and then click on the Brands link. + * This is to workaround the hover menu for now. + */ + const goToBrandsPage = async () => { + await page.goto( + 'wp-admin/edit-tags.php?taxonomy=product_brand&post_type=product' + ); + + // Wait for the Brands page to load. + // This is needed so that checking for existing brands would work. + await page.waitForSelector( '.wp-list-table' ); + }; + + const createBrandIfNotExist = async ( + name, + slug, + parentBrand, + description, + thumbnailFileName + ) => { + // Create "WooCommerce" brand if it does not exist. + const cellVisible = await page + .locator( '#posts-filter' ) + .getByRole( 'cell', { name: slug, exact: true } ) + .isVisible(); + + if ( cellVisible ) { + return; + } + + await page.getByRole( 'textbox', { name: 'Name' } ).click(); + await page.getByRole( 'textbox', { name: 'Name' } ).fill( name ); + await page.getByRole( 'textbox', { name: 'Slug' } ).click(); + await page.getByRole( 'textbox', { name: 'Slug' } ).fill( slug ); + + await page + .getByRole( 'combobox', { name: 'Parent Brand' } ) + .selectOption( { label: parentBrand } ); + + await page.getByRole( 'textbox', { name: 'Description' } ).click(); + await page + .getByRole( 'textbox', { name: 'Description' } ) + .fill( description ); + await page.getByRole( 'button', { name: 'Upload/Add image' } ).click(); + await page.getByRole( 'tab', { name: 'Media Library' } ).click(); + await page.getByRole( 'checkbox', { name: thumbnailFileName } ).click(); + await page.getByRole( 'button', { name: 'Use image' } ).click(); + await page.getByRole( 'button', { name: 'Add New Brand' } ).click(); + + // We should see an "Item added." notice message at the top of the page. + await expect( + page.locator( '#ajax-response' ).getByText( 'Item added.' ) + ).toBeVisible(); + + // We should see the newly created brand in the Brands table. + await expect( + page + .locator( '#posts-filter' ) + .getByRole( 'cell', { name: slug, exact: true } ) + ).toHaveCount( 1 ); + }; + + /** + * Edit a brand. + * + * You must be in the Brands page before calling this function. + * To do so, call `goToBrandsPage()` first. + * + * After a brand is edited, you will be redirected to the Brands page. + */ + const editBrand = async ( + currentName, + { name, slug, parentBrand, description, thumbnailFileName } + ) => { + await page.getByLabel( `“${ currentName }” (Edit)` ).click(); + await page.getByLabel( 'Name' ).fill( name ); + await page.getByLabel( 'Slug' ).fill( slug ); + await page + .getByLabel( 'Parent Brand' ) + .selectOption( { label: parentBrand } ); + await page.getByLabel( 'Description' ).fill( description ); + + await page.getByRole( 'button', { name: 'Upload/Add image' } ).click(); + await page.getByRole( 'tab', { name: 'Media Library' } ).click(); + await page.getByLabel( thumbnailFileName ).click(); + await page.getByRole( 'button', { name: 'Use image' } ).click(); + + await page.getByRole( 'button', { name: 'Update' } ).click(); + + // We should see an "Item updated." notice message at the top of the page. + await expect( + page.locator( '#message' ).getByText( 'Item updated.' ) + ).toBeVisible(); + + // navigate back to Brands page. + await page.getByRole( 'link', { name: '← Go to Brands' } ).click(); + + // confirm that the brand has been updated. + await expect( + page + .locator( '#posts-filter' ) + .getByRole( 'cell', { name: slug, exact: true } ) + ).toHaveCount( 1 ); + }; + + /** + * Delete a brand. + * + * You must be in the Brands page before calling this function. + * To do so, call `goToBrandsPage()` first. + * + * After a brand is deleted, you will be redirected to the Brands page. + */ + const deleteBrand = async ( name ) => { + await page.getByLabel( `“${ name }” (Edit)` ).click(); + + // After clicking the "Delete" button, there will be a confirmation dialog. + page.once( 'dialog', ( dialog ) => { + // Click "OK" to confirm the deletion. + dialog.accept(); + } ); + + // Click on the "Delete" button. + await page.getByRole( 'link', { name: 'Delete' } ).click(); + + // We should now be in the Brands page. + // Confirm that the brand has been deleted and is no longer in the Brands table. + await expect( + page + .locator( '#posts-filter' ) + .getByRole( 'cell', { name, exact: true } ) + ).toHaveCount( 0 ); + }; + + await goToBrandsPage(); + await createBrandIfNotExist( + 'WooCommerce', + 'woocommerce', + 'None', + 'All things WooCommerce!', + 'image-01' + ); + + // Create child brand under the "WooCommerce" parent brand. + await createBrandIfNotExist( + 'WooCommerce Apparels', + 'woocommerce-apparels', + 'WooCommerce', + 'Cool WooCommerce clothings!', + 'image-02' + ); + + // Create a dummy child brand called "WooCommerce Dummy" under the "WooCommerce" parent brand. + await createBrandIfNotExist( + 'WooCommerce Dummy', + 'woocommerce-dummy', + 'WooCommerce', + 'Dummy WooCommerce brand!', + 'image-02' + ); + + // Edit the dummy child brand from "WooCommerce Dummy" to "WooCommerce Dummy Edited". + await editBrand( 'WooCommerce Dummy', { + name: 'WooCommerce Dummy Edited', + slug: 'woocommerce-dummy-edited', + parentBrand: 'WooCommerce', + description: 'Dummy WooCommerce brand edited!', + thumbnailFileName: 'image-03', + } ); + + // Delete the dummy child brand "WooCommerce Dummy Edited". + await deleteBrand( 'WooCommerce Dummy Edited' ); +} ); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js index 2f26691761b..3fd0b4c1717 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js @@ -37,6 +37,12 @@ const couponData = { amount: '60', excludeProductCategories: [ 'Uncategorized' ], }, + excludeProductBrands: { + code: `excludeProductBrands-${ new Date().getTime().toString() }`, + description: 'Exclude product brands coupon', + amount: '65', + excludeProductBrands: [ 'WooCommerce Apparels' ], + }, products: { code: `products-${ new Date().getTime().toString() }`, description: 'Products coupon', @@ -202,6 +208,26 @@ test.describe( 'Restricted coupon management', { tag: [ '@services' ] }, () => { .click(); } ); } + + // Skip Brands tests while behind a feature flag. + const skipBrandsTests = true; + + // set exclude product brands + if ( couponType === 'excludeProductBrands' && ! skipBrandsTests ) { + await test.step( 'set exclude product brands coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'No brands' ) + .pressSequentially( 'WooCommerce Apparels' ); + await page + .getByRole( 'option', { name: 'WooCommerce Apparels' } ) + .click(); + } ); + } // set products if ( couponType === 'products' ) { await test.step( 'set products coupon', async () => { diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-brands-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-brands-test.php new file mode 100644 index 00000000000..5ca5953daf5 --- /dev/null +++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-brands-test.php @@ -0,0 +1,116 @@ +factory()->term->create( + array( + 'taxonomy' => 'product_brand', + 'name' => 'Blah_A', + ) + ); + $term_b_id = $this->factory()->term->create( + array( + 'taxonomy' => 'product_brand', + 'name' => 'Foo_A', + ) + ); + $term_c_id = $this->factory()->term->create( + array( + 'taxonomy' => 'product_brand', + 'name' => 'Blah_B', + ) + ); + + wp_set_post_terms( $simple_product->get_id(), array( $term_a_id, $term_b_id, $term_c_id ), 'product_brand' ); + + add_filter( + 'woocommerce_product_brand_filter_threshold', + function () { + return 3; + } + ); + + $brands_admin = new WC_Brands_Admin(); + ob_start(); + $brands_admin->render_product_brand_filter(); + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertStringContainsString( + ' Date: Wed, 18 Sep 2024 00:00:47 +0530 Subject: [PATCH 31/36] Fixed call to member function is_visible Fatal Error (#51286) * Fixed call to member function is_visible Fatal Error * Implemented the suggestions * Add changefile(s) from automation for the following project(s): woocommerce * Updated the @version tag * Promote 'comment' to actual changelog entry. * Add changefile(s) from automation for the following project(s): woocommerce --------- Co-authored-by: github-actions Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com> --- .../changelog/51286-51176-fixed-fatal-is_visible-func | 4 ++++ plugins/woocommerce/templates/content-product.php | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 plugins/woocommerce/changelog/51286-51176-fixed-fatal-is_visible-func diff --git a/plugins/woocommerce/changelog/51286-51176-fixed-fatal-is_visible-func b/plugins/woocommerce/changelog/51286-51176-fixed-fatal-is_visible-func new file mode 100644 index 00000000000..b604b3112e5 --- /dev/null +++ b/plugins/woocommerce/changelog/51286-51176-fixed-fatal-is_visible-func @@ -0,0 +1,4 @@ +Significance: patch +Type: fix +Comment: Fixed call to a member function is_visible() on string | content-product.php:23 + diff --git a/plugins/woocommerce/templates/content-product.php b/plugins/woocommerce/templates/content-product.php index 7423164e81c..b3bc12ad92c 100644 --- a/plugins/woocommerce/templates/content-product.php +++ b/plugins/woocommerce/templates/content-product.php @@ -12,15 +12,15 @@ * * @see https://woocommerce.com/document/template-structure/ * @package WooCommerce\Templates - * @version 3.6.0 + * @version 9.4.0 */ defined( 'ABSPATH' ) || exit; global $product; -// Ensure visibility. -if ( empty( $product ) || ! $product->is_visible() ) { +// Check if the product is a valid WooCommerce product and ensure its visibility before proceeding. +if ( ! is_a( $product, WC_Product::class ) || ! $product->is_visible() ) { return; } ?> From 5fe53a2e3f66b5272f687e10e98d634ae8926840 Mon Sep 17 00:00:00 2001 From: Moon Date: Tue, 17 Sep 2024 13:46:26 -0700 Subject: [PATCH 32/36] Add locale param to jetpack redirect url (#51392) * Add locale from php * Add changefile(s) from automation for the following project(s): woocommerce * Return a full locale with language and region code * Fix style * Fix error * Lint fixes * Lint fixes --------- Co-authored-by: github-actions --- ...92-update-add-locale-param-to-jetpack-auth | 4 ++ .../src/Admin/API/OnboardingPlugins.php | 53 ++++++++++++++++--- 2 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 plugins/woocommerce/changelog/51392-update-add-locale-param-to-jetpack-auth diff --git a/plugins/woocommerce/changelog/51392-update-add-locale-param-to-jetpack-auth b/plugins/woocommerce/changelog/51392-update-add-locale-param-to-jetpack-auth new file mode 100644 index 00000000000..8e1be059b7d --- /dev/null +++ b/plugins/woocommerce/changelog/51392-update-add-locale-param-to-jetpack-auth @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add `locale` param when redirecting to the Jetpack auth page. \ No newline at end of file diff --git a/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php b/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php index 55f55edfe85..e15c616ae9b 100644 --- a/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php +++ b/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php @@ -193,7 +193,7 @@ class OnboardingPlugins extends WC_REST_Data_Controller { $actions = array_filter( PluginsHelper::get_action_data( $actions ), - function( $action ) use ( $job_id ) { + function ( $action ) use ( $job_id ) { return $action['job_id'] === $job_id; } ); @@ -237,7 +237,10 @@ class OnboardingPlugins extends WC_REST_Data_Controller { } $redirect_url = $request->get_param( 'redirect_url' ); - $calypso_env = defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, [ 'development', 'wpcalypso', 'horizon', 'stage' ], true ) ? WOOCOMMERCE_CALYPSO_ENVIRONMENT : 'production'; + $calypso_env = defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, array( 'development', 'wpcalypso', 'horizon', 'stage' ), true ) ? WOOCOMMERCE_CALYPSO_ENVIRONMENT : 'production'; + + $authorization_url = $manager->get_authorization_url( null, $redirect_url ); + $authorization_url = add_query_arg( 'locale', $this->get_wpcom_locale(), $authorization_url ); if ( Features::is_enabled( 'use-wp-horizon' ) ) { $calypso_env = 'horizon'; @@ -247,15 +250,53 @@ class OnboardingPlugins extends WC_REST_Data_Controller { 'success' => ! $errors->has_errors(), 'errors' => $errors->get_error_messages(), 'url' => add_query_arg( - [ + array( 'from' => $request->get_param( 'from' ), 'calypso_env' => $calypso_env, - ], - $manager->get_authorization_url( null, $redirect_url ) + ), + $authorization_url, ), ]; } + /** + * Return a locale string for wpcom. + * + * @return string + */ + private function get_wpcom_locale() { + // List of locales that should be used with region code. + $locale_to_lang = array( + 'bre' => 'br', + 'de_AT' => 'de-at', + 'de_CH' => 'de-ch', + 'de' => 'de_formal', + 'el' => 'el-po', + 'en_GB' => 'en-gb', + 'es_CL' => 'es-cl', + 'es_MX' => 'es-mx', + 'fr_BE' => 'fr-be', + 'fr_CA' => 'fr-ca', + 'nl_BE' => 'nl-be', + 'nl' => 'nl_formal', + 'pt_BR' => 'pt-br', + 'sr' => 'sr_latin', + 'zh_CN' => 'zh-cn', + 'zh_HK' => 'zh-hk', + 'zh_SG' => 'zh-sg', + 'zh_TW' => 'zh-tw', + ); + + $system_locale = get_locale(); + if ( isset( $locale_to_lang[ $system_locale ] ) ) { + // Return the locale with region code if it's in the list. + return $locale_to_lang[ $system_locale ]; + } + + // If the locale is not in the list, return the language code only. + return explode( '_', $system_locale )[0]; + } + /** * Check whether the current user has permission to install plugins * @@ -405,7 +446,7 @@ class OnboardingPlugins extends WC_REST_Data_Controller { ), $slug ), - 'type' => 'plugin_info_api_error', + 'type' => 'plugin_info_api_error', 'slug' => $slug, 'api_version' => $api->version, 'api_download_link' => $api->download_link, From c2fa5eeff5a4e703dd153cce7f629b02368670eb Mon Sep 17 00:00:00 2001 From: Narendra Sishodiya <32844880+narenin@users.noreply.github.com> Date: Wed, 18 Sep 2024 04:29:22 +0530 Subject: [PATCH 33/36] Updated Product attributes placeholder to e.g. length or weight (#51379) * Updated Product attributes placeholder to e.g. length or weight * Added Changelog * Add changefile(s) from automation for the following project(s): woocommerce * Delete changelog/51134-product-attribute-placeholder-update * Add changefile(s) from automation for the following project(s): woocommerce * Revise changelog. * Add changefile(s) from automation for the following project(s): woocommerce * Satisfy linter. * Increase exactitude to reduce ambiguity levels. --------- Co-authored-by: github-actions Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com> --- .../51379-51134-product-attribute-placeholder-change | 4 ++++ .../admin/meta-boxes/views/html-product-attribute-inner.php | 2 +- .../e2e-pw/tests/merchant/product-create-simple.spec.js | 4 ++-- .../add-variable-product/create-product-attributes.spec.js | 5 +++-- 4 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 plugins/woocommerce/changelog/51379-51134-product-attribute-placeholder-change diff --git a/plugins/woocommerce/changelog/51379-51134-product-attribute-placeholder-change b/plugins/woocommerce/changelog/51379-51134-product-attribute-placeholder-change new file mode 100644 index 00000000000..35d09da84a8 --- /dev/null +++ b/plugins/woocommerce/changelog/51379-51134-product-attribute-placeholder-change @@ -0,0 +1,4 @@ +Significance: patch +Type: fix +Comment: Changed Product attributes placeholder to e.g. length or weight + diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-attribute-inner.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-attribute-inner.php index ef38a24cb54..94112796bb9 100644 --- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-attribute-inner.php +++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-attribute-inner.php @@ -20,7 +20,7 @@ if ( ! defined( 'ABSPATH' ) ) { get_name() ) ); ?> - + diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-create-simple.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-create-simple.spec.js index 77b8a6ebefa..46934a82a1d 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-create-simple.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-create-simple.spec.js @@ -112,7 +112,7 @@ for ( const productType of Object.keys( productData ) ) { .getByRole( 'link', { name: 'Attributes' } ) .click(); await page - .getByPlaceholder( 'f.e. size or color' ) + .getByPlaceholder( 'e.g. length or weight' ) .fill( attributeName ); await page .getByPlaceholder( 'Enter some descriptive text.' ) @@ -183,7 +183,7 @@ for ( const productType of Object.keys( productData ) ) { .getByPlaceholder( '0' ) .fill( productData[ productType ].shipping.weight ); await page - .getByPlaceholder( 'Length' ) + .getByPlaceholder( 'Length', { exact: true } ) .fill( productData[ productType ].shipping.length ); await page .getByPlaceholder( 'Width' ) diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-product-attributes.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-product-attributes.spec.js index 77db0953f5c..9be4189e4b9 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-product-attributes.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-product-attributes.spec.js @@ -39,8 +39,9 @@ test.describe( 'Add product attributes', { tag: '@gutenberg' }, () => { } ); test( 'can add custom product attributes', async ( { page } ) => { - const textbox_attributeName = - page.getByPlaceholder( 'f.e. size or color' ); + const textbox_attributeName = page.getByPlaceholder( + 'e.g. length or weight' + ); const textbox_attributeValues = page.getByPlaceholder( 'Enter options for customers to choose from' ); From cfca07eca87bc7c00a3fc2c9e0ca82fc70fa0449 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 18 Sep 2024 08:57:32 +0800 Subject: [PATCH 34/36] Reducing Noise in Remote Logging (#51357) * Refactor remote logging in WooCommerce This commit refactors the remote logging functionality in WooCommerce to improve its efficiency and flexibility. - In the `class-woocommerce.php` file, the `context` array now includes a new key `remote-logging` to indicate whether the error should be logged remotely if remote logging is enabled. - In the `RemoteLogger.php` file, the `file` key in the `context['error']` array is now assigned to the `$log_data` array, and then unset from the `context['error']` array. - The `should_handle` method in the `RemoteLogger` class now checks for the presence of the `remote-logging` key in the `context` array. If it is not set or set to `false`, the log is ignored. - The `RemoteLoggerTest.php` file includes new test cases to ensure that the `should_handle` method returns `false` when the `remote-logging` key is not present in the `context` array. These changes improve the remote logging functionality in WooCommerce and make it more robust and efficient. * Revert log format change * Set remote-logging context to true in log remote event method * Add changelog * revert change * revert change --- .../api/remote-logging/remote-logging.php | 5 ++++- .../enhance-reduce-remote-logging-noise | 4 ++++ .../enhance-reduce-remote-logging-noise | 4 ++++ .../woocommerce/includes/class-woocommerce.php | 6 ++++-- .../src/Internal/Logging/RemoteLogger.php | 8 ++++++++ .../src/Internal/Logging/RemoteLoggerTest.php | 18 +++++++++++++----- 6 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 plugins/woocommerce-beta-tester/changelog/enhance-reduce-remote-logging-noise create mode 100644 plugins/woocommerce/changelog/enhance-reduce-remote-logging-noise diff --git a/plugins/woocommerce-beta-tester/api/remote-logging/remote-logging.php b/plugins/woocommerce-beta-tester/api/remote-logging/remote-logging.php index 80d518e03f2..fa54e7ef426 100644 --- a/plugins/woocommerce-beta-tester/api/remote-logging/remote-logging.php +++ b/plugins/woocommerce-beta-tester/api/remote-logging/remote-logging.php @@ -100,7 +100,10 @@ function log_remote_event() { time(), 'critical', 'Test PHP event from WC Beta Tester', - array( 'source' => 'wc-beta-tester' ) + array( + 'source' => 'wc-beta-tester', + 'remote-logging' => true, + ) ); if ( $result ) { diff --git a/plugins/woocommerce-beta-tester/changelog/enhance-reduce-remote-logging-noise b/plugins/woocommerce-beta-tester/changelog/enhance-reduce-remote-logging-noise new file mode 100644 index 00000000000..c5e1fb58516 --- /dev/null +++ b/plugins/woocommerce-beta-tester/changelog/enhance-reduce-remote-logging-noise @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Set remote-logging context to true in log remote event method diff --git a/plugins/woocommerce/changelog/enhance-reduce-remote-logging-noise b/plugins/woocommerce/changelog/enhance-reduce-remote-logging-noise new file mode 100644 index 00000000000..736e9c0d3f8 --- /dev/null +++ b/plugins/woocommerce/changelog/enhance-reduce-remote-logging-noise @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Reducing noise in remote logging diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php index 0c0c9c14a72..85ce78999c1 100644 --- a/plugins/woocommerce/includes/class-woocommerce.php +++ b/plugins/woocommerce/includes/class-woocommerce.php @@ -383,8 +383,10 @@ final class WooCommerce { unset( $error_copy['message'] ); $context = array( - 'source' => 'fatal-errors', - 'error' => $error_copy, + 'source' => 'fatal-errors', + 'error' => $error_copy, + // Indicate that this error should be logged remotely if remote logging is enabled. + 'remote-logging' => true, ); if ( false !== strpos( $message, 'Stack trace:' ) ) { diff --git a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php index 2f8e3d5ab8a..6793432c973 100644 --- a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php +++ b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php @@ -103,6 +103,8 @@ class RemoteLogger extends \WC_Log_Handler { $extra_attrs = $context['extra'] ?? array(); unset( $context['extra'] ); + unset( $context['remote-logging'] ); + // 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 ); @@ -162,9 +164,15 @@ class RemoteLogger extends \WC_Log_Handler { * @return bool True if the log should be handled. */ protected function should_handle( $level, $message, $context ) { + // Ignore logs that are not opted in for remote logging. + if ( ! isset( $context['remote-logging'] ) || false === $context['remote-logging'] ) { + return false; + } + 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; diff --git a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php index 1133880bad2..1178a2bf95b 100644 --- a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php +++ b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php @@ -348,7 +348,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { $setup( $this ); - $result = $this->invoke_private_method( $this->sut, 'should_handle', array( $level, 'Test message', array() ) ); + $result = $this->invoke_private_method( $this->sut, 'should_handle', array( $level, 'Test message', array( 'remote-logging' => true ) ) ); $this->assertEquals( $expected, $result ); } @@ -377,6 +377,14 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { ); } + /** + * @testdox Test should_handle returns false without remote-logging context + */ + public function test_should_handle_no_remote_logging_context() { + $result = $this->invoke_private_method( $this->sut, 'should_handle', array( 'error', 'Test message', array() ) ); + $this->assertFalse( $result, 'should_handle should return false without remote-logging context' ); + } + /** * @testdox handle method applies filter and doesn't send logs when filtered to null */ @@ -390,7 +398,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { add_filter( 'woocommerce_remote_logger_formatted_log_data', fn() => null, 10, 4 ); add_filter( 'pre_http_request', fn() => $this->fail( 'wp_safe_remote_post should not be called' ), 10, 3 ); - $this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array() ) ); + $this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array( 'remote-logging' => true ) ) ); } /** @@ -404,7 +412,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { $this->sut->set_is_dev_or_local( true ); $this->sut->method( 'is_remote_logging_allowed' )->willReturn( true ); - $this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array() ) ); + $this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array( 'remote-logging' => true ) ) ); } /** @@ -435,7 +443,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { 3 ); - $this->assertTrue( $this->sut->handle( time(), 'critical', 'Test message', array() ) ); + $this->assertTrue( $this->sut->handle( time(), 'critical', 'Test message', array( 'remote-logging' => true ) ) ); $this->assertTrue( WC_Rate_Limiter::retried_too_soon( RemoteLogger::RATE_LIMIT_ID ) ); } @@ -462,7 +470,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { 3 ); - $this->assertFalse( $this->sut->handle( time(), 'critical', 'Test message', array() ) ); + $this->assertFalse( $this->sut->handle( time(), 'critical', 'Test message', array( 'remote-logging' => true ) ) ); $this->assertTrue( WC_Rate_Limiter::retried_too_soon( RemoteLogger::RATE_LIMIT_ID ) ); } From cf7fd8303c9df5ff980965db506a1f66f9ce7ef2 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 18 Sep 2024 08:58:10 +0800 Subject: [PATCH 35/36] Improve remote logging structure and content (#51360) * Refactor RemoteLogger to format error file paths and remove absolute paths * Add changelog * Change "**" -> "./" --- .../changelog/enhance-improve-log-structure | 4 ++++ .../src/Internal/Logging/RemoteLogger.php | 15 ++++++++------- .../src/Internal/Logging/RemoteLoggerTest.php | 16 ++++++++++++---- 3 files changed, 24 insertions(+), 11 deletions(-) create mode 100644 plugins/woocommerce/changelog/enhance-improve-log-structure diff --git a/plugins/woocommerce/changelog/enhance-improve-log-structure b/plugins/woocommerce/changelog/enhance-improve-log-structure new file mode 100644 index 00000000000..f3fe5a6215f --- /dev/null +++ b/plugins/woocommerce/changelog/enhance-improve-log-structure @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Improve remote logging structure and content diff --git a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php index 6793432c973..19a04d84fec 100644 --- a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php +++ b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php @@ -97,8 +97,9 @@ class RemoteLogger extends \WC_Log_Handler { unset( $context['tags'] ); } - if ( isset( $context['error'] ) && is_array( $context['error'] ) && ! empty( $context['error']['file'] ) ) { - $context['error']['file'] = $this->sanitize( $context['error']['file'] ); + if ( isset( $context['error']['file'] ) && is_string( $context['error']['file'] ) && '' !== $context['error']['file'] ) { + $log_data['file'] = $this->sanitize( $context['error']['file'] ); + unset( $context['error']['file'] ); } $extra_attrs = $context['extra'] ?? array(); @@ -371,7 +372,7 @@ class RemoteLogger extends \WC_Log_Handler { * * The trace is sanitized by: * - * 1. Remove the absolute path to the WooCommerce plugin directory. + * 1. Remove the absolute path to the plugin directory based on WC_ABSPATH. This is more accurate than using WP_PLUGIN_DIR when the plugin is symlinked. * 2. Remove the absolute path to the WordPress root directory. * * For example, the trace: @@ -387,12 +388,12 @@ class RemoteLogger extends \WC_Log_Handler { return $message; } - $wc_path = StringUtil::normalize_local_path_slashes( WC_ABSPATH ); - $wp_path = StringUtil::normalize_local_path_slashes( ABSPATH ); + $plugin_path = StringUtil::normalize_local_path_slashes( trailingslashit( dirname( WC_ABSPATH ) ) ); + $wp_path = StringUtil::normalize_local_path_slashes( trailingslashit( ABSPATH ) ); $sanitized = str_replace( - array( $wc_path, $wp_path ), - array( '**/' . dirname( WC_PLUGIN_BASENAME ) . '/', '**/' ), + array( $plugin_path, $wp_path ), + array( './', './' ), $message ); diff --git a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php index 1178a2bf95b..88218f1da75 100644 --- a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php +++ b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php @@ -228,7 +228,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { array( 'feature' => 'woocommerce_core', 'severity' => 'error', - 'message' => 'Fatal error occurred at line 123 in **/wp-content/file.php', + 'message' => 'Fatal error occurred at line 123 in ./wp-content/file.php', 'tags' => array( 'woocommerce', 'php', 'tag1', 'tag2' ), ), ), @@ -236,7 +236,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { 'error', 'Test error message', array( 'backtrace' => ABSPATH . 'wp-content/plugins/woocommerce/file.php' ), - array( 'trace' => '**/woocommerce/file.php' ), + array( 'trace' => './woocommerce/file.php' ), ), 'log with extra attributes' => array( 'error', @@ -254,6 +254,14 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { ), ), ), + 'log with error file' => array( + 'error', + 'Test error message', + array( 'error' => array( 'file' => WC_ABSPATH . 'includes/class-wc-test.php' ) ), + array( + 'file' => './woocommerce/includes/class-wc-test.php', + ), + ), ); } @@ -536,7 +544,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { */ public function test_sanitize() { $message = WC_ABSPATH . 'includes/class-wc-test.php on line 123'; - $expected = '**/woocommerce/includes/class-wc-test.php on line 123'; + $expected = './woocommerce/includes/class-wc-test.php on line 123'; $result = $this->invoke_private_method( $this->sut, 'sanitize', array( $message ) ); $this->assertEquals( $expected, $result ); } @@ -549,7 +557,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { WC_ABSPATH . 'includes/class-wc-test.php:123', ABSPATH . 'wp-includes/plugin.php:456', ); - $expected = "**/woocommerce/includes/class-wc-test.php:123\n**/wp-includes/plugin.php:456"; + $expected = "./woocommerce/includes/class-wc-test.php:123\n./wp-includes/plugin.php:456"; $result = $this->invoke_private_method( $this->sut, 'sanitize_trace', array( $trace ) ); $this->assertEquals( $expected, $result ); } From 2e3013555ea27557fa5e186fca5d03574081997a Mon Sep 17 00:00:00 2001 From: Sam Seay Date: Wed, 18 Sep 2024 10:13:47 +0800 Subject: [PATCH 36/36] Check if button element exists when triggering added_to_cart (#51449) --- .../changelog/51449-dev-harden-added-to-cart | 4 +++ .../client/legacy/js/frontend/add-to-cart.js | 30 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) create mode 100644 plugins/woocommerce/changelog/51449-dev-harden-added-to-cart diff --git a/plugins/woocommerce/changelog/51449-dev-harden-added-to-cart b/plugins/woocommerce/changelog/51449-dev-harden-added-to-cart new file mode 100644 index 00000000000..99351de4130 --- /dev/null +++ b/plugins/woocommerce/changelog/51449-dev-harden-added-to-cart @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix bug where manually triggering `added_to_cart` event without a button element caused an Exception. \ No newline at end of file diff --git a/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js b/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js index a99e4394750..dd2e0872c86 100644 --- a/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js +++ b/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js @@ -173,6 +173,8 @@ jQuery( function( $ ) { * Update cart page elements after add to cart events. */ AddToCartHandler.prototype.updateButton = function( e, fragments, cart_hash, $button ) { + // Some themes and plugins manually trigger added_to_cart without passing a button element, which in turn calls this function. + // If there is no button we don't want to crash. $button = typeof $button === 'undefined' ? false : $button; if ( $button ) { @@ -222,19 +224,25 @@ jQuery( function( $ ) { * Update cart live region message after add/remove cart events. */ AddToCartHandler.prototype.alertCartUpdated = function( e, fragments, cart_hash, $button ) { - var message = $button.data( 'success_message' ); + // Some themes and plugins manually trigger added_to_cart without passing a button element, which in turn calls this function. + // If there is no button we don't want to crash. + $button = typeof $button === 'undefined' ? false : $button; - if ( !message ) { - return; - } + if ( $button ) { + var message = $button.data( 'success_message' ); + + if ( !message ) { + return; + } - // If the response after adding/removing an item to/from the cart is really fast, - // screen readers may not have time to identify the changes in the live region element. - // So, we add a delay to ensure an interval between messages. - e.data.addToCartHandler.$liveRegion - .delay(1000) - .text( message ) - .attr( 'aria-relevant', 'all' ); + // If the response after adding/removing an item to/from the cart is really fast, + // screen readers may not have time to identify the changes in the live region element. + // So, we add a delay to ensure an interval between messages. + e.data.addToCartHandler.$liveRegion + .delay(1000) + .text( message ) + .attr( 'aria-relevant', 'all' ); + } }; /**