From ea6e7295ecf2424e9b5187c4a8d39cff21149fe0 Mon Sep 17 00:00:00 2001 From: Jason Kytros Date: Tue, 17 Sep 2024 18:13:46 +0300 Subject: [PATCH] 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 ''; + + 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( + '