From e6f50ae4bcd2909a52bf6cae8fe6ecf5052fc3c2 Mon Sep 17 00:00:00 2001 From: Katja Paavola Date: Wed, 8 Oct 2025 14:41:26 +0100 Subject: [PATCH 01/11] Initial Product Attribute Filter This initial commit replicates the existing Taxonomy filter setup to add a new "Product Attributes" filter. This filter will display global WooCommerce attributes only. --- projects/packages/search/src/class-helper.php | 32 ++++- .../classic-search/class-classic-search.php | 122 ++++++++++++++++++ .../components/search-filter.jsx | 33 +++++ .../search/src/instant-search/lib/api.js | 4 + .../search/src/instant-search/lib/filters.js | 18 ++- .../src/widgets/class-search-widget.php | 10 ++ .../widgets/css/search-widget-admin-ui.css | 5 + 7 files changed, 220 insertions(+), 4 deletions(-) diff --git a/projects/packages/search/src/class-helper.php b/projects/packages/search/src/class-helper.php index 3fc06fbc905a9..3dd47c7ab1ec9 100644 --- a/projects/packages/search/src/class-helper.php +++ b/projects/packages/search/src/class-helper.php @@ -182,9 +182,24 @@ public static function get_filters_from_widgets( $allowed_widget_ids = null ) { } $type = ( isset( $widget_filter['type'] ) ) ? $widget_filter['type'] : ''; - $key = sprintf( '%s_%d', $type, count( $filters ) ); - $filters[ $key ] = $widget_filter; + // If this is a product_attribute filter with no specific attribute, expand it to all global attributes. + if ( 'product_attribute' === $type && empty( $widget_filter['attribute'] ) ) { + if ( function_exists( 'wc_get_attribute_taxonomies' ) ) { + $product_attributes = wc_get_attribute_taxonomies(); + foreach ( $product_attributes as $attribute ) { + $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); + $key = sprintf( '%s_%d', $type, count( $filters ) ); + $expanded_filter = $widget_filter; + $expanded_filter['attribute'] = $attribute_name; + $expanded_filter['name'] = $attribute->attribute_label; + $filters[ $key ] = $expanded_filter; + } + } + } else { + $key = sprintf( '%s_%d', $type, count( $filters ) ); + $filters[ $key ] = $widget_filter; + } } } @@ -282,6 +297,19 @@ public static function generate_widget_filter_name( $widget_filter ) { $name = $tax->labels->name; } break; + + case 'product_attribute': + if ( isset( $widget_filter['attribute'] ) && ! empty( $widget_filter['attribute'] ) ) { + $attribute_taxonomy = get_taxonomy( $widget_filter['attribute'] ); + if ( $attribute_taxonomy && isset( $attribute_taxonomy->label ) ) { + $name = $attribute_taxonomy->label; + } else { + $name = _x( 'Product Attributes', 'label for filtering posts', 'jetpack-search-pkg' ); + } + } else { + $name = _x( 'Product Attributes', 'label for filtering posts', 'jetpack-search-pkg' ); + } + break; } return $name; diff --git a/projects/packages/search/src/classic-search/class-classic-search.php b/projects/packages/search/src/classic-search/class-classic-search.php index d39953f7c9547..2ebc8d759a173 100644 --- a/projects/packages/search/src/classic-search/class-classic-search.php +++ b/projects/packages/search/src/classic-search/class-classic-search.php @@ -1228,6 +1228,11 @@ public function add_aggregations_to_es_query_builder( array $aggregations, $buil case 'date_histogram': $this->add_date_histogram_aggregation_to_es_query_builder( $aggregation, $label, $builder ); + break; + + case 'product_attribute': + $this->add_product_attribute_aggregation_to_es_query_builder( $aggregation, $label, $builder ); + break; } } @@ -1341,6 +1346,68 @@ public function add_date_histogram_aggregation_to_es_query_builder( array $aggre ); } + /** + * Given an individual product_attribute aggregation, add it to the query builder object for use in Elasticsearch. + * + * @since 0.44.0 + * + * @param array $aggregation The aggregation to add to the query builder. + * @param string $label The 'label' (unique id) for this aggregation. + * @param \Automattic\Jetpack\Search\WPES\Query_Builder $builder The builder instance that is creating the Elasticsearch query. + */ + public function add_product_attribute_aggregation_to_es_query_builder( array $aggregation, $label, $builder ) { + // For Classic Search: if no specific attribute is provided, query all global product attributes. + // Note: Instant Search handles this differently via get_filters_from_widgets() expansion. + if ( empty( $aggregation['attribute'] ) ) { + if ( ! function_exists( 'wc_get_attribute_taxonomies' ) ) { + return; + } + + $product_attributes = wc_get_attribute_taxonomies(); + + if ( empty( $product_attributes ) ) { + return; + } + + foreach ( $product_attributes as $attribute ) { + $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); + $field = 'taxonomy.' . $attribute_name; + $agg_label = $label . '_' . $attribute_name; + + $builder->add_aggs( + $agg_label, + array( + 'terms' => array( + 'field' => $field . '.slug', + 'size' => min( (int) $aggregation['count'], $this->max_aggregations_count ), + ), + ) + ); + + // Store this aggregation in the aggregations array so get_filters() can process it. + $this->aggregations[ $agg_label ] = array( + 'type' => 'product_attribute', + 'attribute' => $attribute_name, + 'count' => $aggregation['count'], + 'name' => isset( $aggregation['name'] ) ? $aggregation['name'] : '', + ); + } + } else { + // Handle specific attribute (for backwards compatibility). + $field = 'taxonomy.' . $aggregation['attribute']; + + $builder->add_aggs( + $label, + array( + 'terms' => array( + 'field' => $field . '.slug', + 'size' => min( (int) $aggregation['count'], $this->max_aggregations_count ), + ), + ) + ); + } + } + /** * And an existing filter object with a list of additional filters. * @@ -1455,6 +1522,10 @@ public function get_filters( ?WP_Query $query = null ) { continue; } + if ( ! isset( $this->aggregations[ $label ] ) ) { + continue; + } + $type = $this->aggregations[ $label ]['type']; $aggregation_data[ $label ]['buckets'] = array(); @@ -1599,6 +1670,57 @@ public function get_filters( ?WP_Query $query = null ) { break; + case 'product_attribute': + $attribute_taxonomy = $this->aggregations[ $label ]['attribute']; + + $term = get_term_by( 'slug', $item['key'], $attribute_taxonomy ); + + if ( ! $term ) { + continue 2; // switch() is considered a looping structure. + } + + $tax_query_var = $this->get_taxonomy_query_var( $attribute_taxonomy ); + + if ( ! $tax_query_var ) { + continue 2; + } + + // Figure out which terms are already selected for this attribute. + $existing_attribute_slugs = array(); + if ( ! empty( $query->tax_query ) && ! empty( $query->tax_query->queries ) && is_array( $query->tax_query->queries ) ) { + foreach ( $query->tax_query->queries as $tax_query ) { + if ( is_array( $tax_query ) && $attribute_taxonomy === $tax_query['taxonomy'] && + 'slug' === $tax_query['field'] && + is_array( $tax_query['terms'] ) ) { + $existing_attribute_slugs = array_merge( $existing_attribute_slugs, $tax_query['terms'] ); + } + } + } + + $query_vars = array( + $tax_query_var => implode( '+', array_merge( $existing_attribute_slugs, array( $term->slug ) ) ), + ); + + $name = $term->name; + + // Let's determine if this term is active or not. + if ( in_array( $item['key'], $existing_attribute_slugs, true ) ) { + $active = true; + + $slug_count = count( $existing_attribute_slugs ); + + if ( $slug_count > 1 ) { + $remove_url = Helper::add_query_arg( + $tax_query_var, + rawurlencode( implode( '+', array_diff( $existing_attribute_slugs, array( $item['key'] ) ) ) ) + ); + } else { + $remove_url = Helper::remove_query_arg( $tax_query_var ); + } + } + + break; + case 'date_histogram': $timestamp = $item['key'] / 1000; diff --git a/projects/packages/search/src/instant-search/components/search-filter.jsx b/projects/packages/search/src/instant-search/components/search-filter.jsx index e2131d0acd8c7..6e75dd8fcbc11 100644 --- a/projects/packages/search/src/instant-search/components/search-filter.jsx +++ b/projects/packages/search/src/instant-search/components/search-filter.jsx @@ -48,6 +48,8 @@ class SearchFilter extends Component { return `${ this.props.configuration.interval }_${ this.props.configuration.field }`; } else if ( this.props.type === 'taxonomy' ) { return this.props.configuration.taxonomy; + } else if ( this.props.type === 'productAttribute' ) { + return this.props.configuration.attribute; } else if ( this.props.type === 'group' ) { return this.props.configuration.filter_id; } @@ -194,6 +196,32 @@ class SearchFilter extends Component { ); }; + renderProductAttribute = ( { key, doc_count: count } ) => { + // Product attribute keys contain slug and name separated by a slash + const [ slug, name ] = key && key.split( /\/(.+)/ ); + + return ( +
+ + + +
+ ); + }; + renderGroup = group => { return (
@@ -241,6 +269,10 @@ class SearchFilter extends Component { return this.props.aggregation.buckets.map( this.renderTaxonomy ); } + renderProductAttributes() { + return this.props.aggregation.buckets.map( this.renderProductAttribute ); + } + renderGroups() { return this.props.configuration.values.map( this.renderGroup ); } @@ -271,6 +303,7 @@ class SearchFilter extends Component { { this.props.type === 'author' && this.renderAuthors() } { this.props.type === 'blogId' && this.renderBlogIds() } { this.props.type === 'taxonomy' && this.renderTaxonomies() } + { this.props.type === 'productAttribute' && this.renderProductAttributes() }
) } diff --git a/projects/packages/search/src/instant-search/lib/api.js b/projects/packages/search/src/instant-search/lib/api.js index 678b25d1733a8..9d5db32125b56 100644 --- a/projects/packages/search/src/instant-search/lib/api.js +++ b/projects/packages/search/src/instant-search/lib/api.js @@ -83,6 +83,10 @@ function generateAggregation( filter ) { return { terms: { field, size: filter.count } }; } + case 'product_attribute': { + const field = `taxonomy.${ filter.attribute }.slug_slash_name`; + return { terms: { field, size: filter.count } }; + } case 'post_type': { return { terms: { field: filter.type, size: filter.count } }; } diff --git a/projects/packages/search/src/instant-search/lib/filters.js b/projects/packages/search/src/instant-search/lib/filters.js index a41257261c324..a44f6d075f3b7 100644 --- a/projects/packages/search/src/instant-search/lib/filters.js +++ b/projects/packages/search/src/instant-search/lib/filters.js @@ -41,8 +41,13 @@ export function getFilterKeys( .map( w => w.filters ) .filter( filters => Array.isArray( filters ) ) .reduce( ( filtersA, filtersB ) => filtersA.concat( filtersB ), [] ) - .filter( filter => filter.type === 'taxonomy' ) - .forEach( filter => keys.add( filter.taxonomy ) ); + .forEach( filter => { + if ( filter.type === 'taxonomy' ) { + keys.add( filter.taxonomy ); + } else if ( filter.type === 'product_attribute' && filter.attribute ) { + keys.add( filter.attribute ); + } + } ); return [ ...keys ]; } @@ -141,6 +146,8 @@ export function mapFilterToFilterKey( filter ) { return 'authors'; } else if ( filter.type === 'blog_id' ) { return 'blog_ids'; + } else if ( filter.type === 'product_attribute' ) { + return filter.attribute; } else if ( filter.type === 'group' ) { return filter.filter_id; } @@ -183,6 +190,11 @@ export function mapFilterKeyToFilter( filterKey ) { return { type: 'group', }; + } else if ( filterKey.startsWith( 'pa_' ) ) { + return { + type: 'product_attribute', + attribute: filterKey, + }; } return { @@ -208,6 +220,8 @@ export function mapFilterToType( filter ) { return 'author'; } else if ( filter.type === 'blog_id' ) { return 'blogId'; + } else if ( filter.type === 'product_attribute' ) { + return 'productAttribute'; } else if ( filter.type === 'group' ) { return 'group'; } diff --git a/projects/packages/search/src/widgets/class-search-widget.php b/projects/packages/search/src/widgets/class-search-widget.php index 54d30460935a2..0bf7a37027777 100644 --- a/projects/packages/search/src/widgets/class-search-widget.php +++ b/projects/packages/search/src/widgets/class-search-widget.php @@ -700,6 +700,13 @@ public function update( $new_instance, $old_instance ) { // phpcs:ignore Variabl 'interval' => sanitize_key( $new_instance['date_histogram_interval'][ $index ] ), ); break; + case 'product_attribute': + $filters[] = array( + 'name' => sanitize_text_field( $new_instance['filter_name'][ $index ] ), + 'type' => 'product_attribute', + 'count' => $count, + ); + break; } } } @@ -996,6 +1003,9 @@ public function render_widget_edit_filter( $filter, $is_template = false, $is_in +

diff --git a/projects/packages/search/src/widgets/css/search-widget-admin-ui.css b/projects/packages/search/src/widgets/css/search-widget-admin-ui.css index 4850c0e87318c..c407b62254db1 100644 --- a/projects/packages/search/src/widgets/css/search-widget-admin-ui.css +++ b/projects/packages/search/src/widgets/css/search-widget-admin-ui.css @@ -67,6 +67,11 @@ display: none; } +/* When product attribute is selected, hide taxonomy selector */ +.jetpack-search-filters-widget__filter.is-product_attribute .jetpack-search-filters-widget__taxonomy-select { + display: none; +} + .jetpack-search-filters-widget.hide-post-types .jetpack-search-filters-widget__post-types-select { display: none; } From 2de552e042df0f71d0d5c2aea33112fd9f341060 Mon Sep 17 00:00:00 2001 From: Katja Paavola Date: Wed, 8 Oct 2025 15:34:27 +0100 Subject: [PATCH 02/11] changelog --- .../search/changelog/add-instant-search-wc-attributes | 4 ++++ .../jetpack/changelog/add-instant-search-wc-attributes | 4 ++++ .../plugins/search/changelog/add-instant-search-wc-attributes | 4 ++++ 3 files changed, 12 insertions(+) create mode 100644 projects/packages/search/changelog/add-instant-search-wc-attributes create mode 100644 projects/plugins/jetpack/changelog/add-instant-search-wc-attributes create mode 100644 projects/plugins/search/changelog/add-instant-search-wc-attributes diff --git a/projects/packages/search/changelog/add-instant-search-wc-attributes b/projects/packages/search/changelog/add-instant-search-wc-attributes new file mode 100644 index 0000000000000..8c7341b2bae60 --- /dev/null +++ b/projects/packages/search/changelog/add-instant-search-wc-attributes @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Instant Search: Add global WooCommerce Product Attributes as filter options. diff --git a/projects/plugins/jetpack/changelog/add-instant-search-wc-attributes b/projects/plugins/jetpack/changelog/add-instant-search-wc-attributes new file mode 100644 index 0000000000000..10e44bb89db93 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-instant-search-wc-attributes @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Instant Search: Add global WooCommerce Product Attributes as filter options. diff --git a/projects/plugins/search/changelog/add-instant-search-wc-attributes b/projects/plugins/search/changelog/add-instant-search-wc-attributes new file mode 100644 index 0000000000000..8c7341b2bae60 --- /dev/null +++ b/projects/plugins/search/changelog/add-instant-search-wc-attributes @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Instant Search: Add global WooCommerce Product Attributes as filter options. From 2b2bc760addb5cb95a7987cde3d62937697a3612 Mon Sep 17 00:00:00 2001 From: Katja Paavola Date: Thu, 9 Oct 2025 21:02:43 +0100 Subject: [PATCH 03/11] Fix PA references - Ensure we're retrieving product attributes correctly - Ensure we don't miss the 'case' statement needed in class-inline-search.php --- .../src/inline-search/class-inline-search.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/projects/packages/search/src/inline-search/class-inline-search.php b/projects/packages/search/src/inline-search/class-inline-search.php index 382d1597da1e2..eec53090e1f7b 100644 --- a/projects/packages/search/src/inline-search/class-inline-search.php +++ b/projects/packages/search/src/inline-search/class-inline-search.php @@ -291,11 +291,11 @@ public function convert_wp_query_to_api_args( array $args ) { switch ( $aggregation['type'] ) { case 'taxonomy': if ( $aggregation['taxonomy'] === 'post_tag' ) { - $field = 'tag.slug'; + $field = 'tag.slug_slash_name'; } elseif ( $aggregation['taxonomy'] === 'category' ) { - $field = 'category.slug'; + $field = 'category.slug_slash_name'; } else { - $field = "taxonomy.{$aggregation['taxonomy']}.slug"; + $field = "taxonomy.{$aggregation['taxonomy']}.slug_slash_name"; } $aggregations[ $label ] = array( 'terms' => array( @@ -330,6 +330,17 @@ public function convert_wp_query_to_api_args( array $args ) { ), ); break; + case 'product_attribute': + if ( ! empty( $aggregation['attribute'] ) ) { + $field = "taxonomy.{$aggregation['attribute']}.slug_slash_name"; + $aggregations[ $label ] = array( + 'terms' => array( + 'field' => $field, + 'size' => $size, + ), + ); + } + break; } } From 4ee7a332875bb672822d58f32a565910069dce89 Mon Sep 17 00:00:00 2001 From: Katja Paavola Date: Fri, 10 Oct 2025 20:50:01 +0100 Subject: [PATCH 04/11] Clean up methods Some method cleanup and PHAN ignores following existence checks --- projects/packages/search/src/class-helper.php | 8 +- .../classic-search/class-classic-search.php | 190 +++++++++--------- 2 files changed, 101 insertions(+), 97 deletions(-) diff --git a/projects/packages/search/src/class-helper.php b/projects/packages/search/src/class-helper.php index 3dd47c7ab1ec9..bc42ff7eea8be 100644 --- a/projects/packages/search/src/class-helper.php +++ b/projects/packages/search/src/class-helper.php @@ -185,10 +185,10 @@ public static function get_filters_from_widgets( $allowed_widget_ids = null ) { // If this is a product_attribute filter with no specific attribute, expand it to all global attributes. if ( 'product_attribute' === $type && empty( $widget_filter['attribute'] ) ) { - if ( function_exists( 'wc_get_attribute_taxonomies' ) ) { - $product_attributes = wc_get_attribute_taxonomies(); + if ( function_exists( 'wc_get_attribute_taxonomies' ) || function_exists( 'wc_attribute_taxonomy_name' ) ) { + $product_attributes = wc_get_attribute_taxonomies(); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. foreach ( $product_attributes as $attribute ) { - $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); + $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. $key = sprintf( '%s_%d', $type, count( $filters ) ); $expanded_filter = $widget_filter; $expanded_filter['attribute'] = $attribute_name; @@ -299,7 +299,7 @@ public static function generate_widget_filter_name( $widget_filter ) { break; case 'product_attribute': - if ( isset( $widget_filter['attribute'] ) && ! empty( $widget_filter['attribute'] ) ) { + if ( ! empty( $widget_filter['attribute'] ) ) { $attribute_taxonomy = get_taxonomy( $widget_filter['attribute'] ); if ( $attribute_taxonomy && isset( $attribute_taxonomy->label ) ) { $name = $attribute_taxonomy->label; diff --git a/projects/packages/search/src/classic-search/class-classic-search.php b/projects/packages/search/src/classic-search/class-classic-search.php index 2ebc8d759a173..4f183e2991122 100644 --- a/projects/packages/search/src/classic-search/class-classic-search.php +++ b/projects/packages/search/src/classic-search/class-classic-search.php @@ -1356,58 +1356,62 @@ public function add_date_histogram_aggregation_to_es_query_builder( array $aggre * @param \Automattic\Jetpack\Search\WPES\Query_Builder $builder The builder instance that is creating the Elasticsearch query. */ public function add_product_attribute_aggregation_to_es_query_builder( array $aggregation, $label, $builder ) { - // For Classic Search: if no specific attribute is provided, query all global product attributes. - // Note: Instant Search handles this differently via get_filters_from_widgets() expansion. - if ( empty( $aggregation['attribute'] ) ) { - if ( ! function_exists( 'wc_get_attribute_taxonomies' ) ) { - return; - } + // Handle a specific attribute (from expanded widget filters or direct API usage). + if ( ! empty( $aggregation['attribute'] ) ) { + $this->build_product_attribute_agg( $aggregation['attribute'], $aggregation['count'], $label, $builder ); + return; + } - $product_attributes = wc_get_attribute_taxonomies(); + if ( ! function_exists( 'wc_get_attribute_taxonomies' ) || ! function_exists( 'wc_attribute_taxonomy_name' ) ) { + return; + } - if ( empty( $product_attributes ) ) { - return; - } + $product_attributes = wc_get_attribute_taxonomies(); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. - foreach ( $product_attributes as $attribute ) { - $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); - $field = 'taxonomy.' . $attribute_name; - $agg_label = $label . '_' . $attribute_name; + if ( empty( $product_attributes ) ) { + return; + } - $builder->add_aggs( - $agg_label, - array( - 'terms' => array( - 'field' => $field . '.slug', - 'size' => min( (int) $aggregation['count'], $this->max_aggregations_count ), - ), - ) - ); + foreach ( $product_attributes as $attribute ) { + $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. + $agg_label = $label . '_' . $attribute_name; - // Store this aggregation in the aggregations array so get_filters() can process it. - $this->aggregations[ $agg_label ] = array( - 'type' => 'product_attribute', - 'attribute' => $attribute_name, - 'count' => $aggregation['count'], - 'name' => isset( $aggregation['name'] ) ? $aggregation['name'] : '', - ); - } - } else { - // Handle specific attribute (for backwards compatibility). - $field = 'taxonomy.' . $aggregation['attribute']; + $this->build_product_attribute_agg( $attribute_name, $aggregation['count'], $agg_label, $builder ); - $builder->add_aggs( - $label, - array( - 'terms' => array( - 'field' => $field . '.slug', - 'size' => min( (int) $aggregation['count'], $this->max_aggregations_count ), - ), - ) + // Store this aggregation in the aggregations array so get_filters() can process it. + $this->aggregations[ $agg_label ] = array( + 'type' => 'product_attribute', + 'attribute' => $attribute_name, + 'count' => $aggregation['count'], + 'name' => $aggregation['name'] ?? '', ); } } + /** + * Builds and adds a product attribute aggregation to the query builder. + * + * @since 0.44.0 + * + * @param string $attribute_name The attribute taxonomy name. + * @param int $count The maximum number of buckets to return. + * @param string $label The aggregation label. + * @param \Automattic\Jetpack\Search\WPES\Query_Builder $builder The query builder instance. + */ + private function build_product_attribute_agg( $attribute_name, $count, $label, $builder ) { + $field = 'taxonomy.' . $attribute_name . '.slug'; + + $builder->add_aggs( + $label, + array( + 'terms' => array( + 'field' => $field, + 'size' => min( (int) $count, $this->max_aggregations_count ), + ), + ) + ); + } + /** * And an existing filter object with a list of additional filters. * @@ -1608,6 +1612,57 @@ public function get_filters( ?WP_Query $query = null ) { break; + case 'product_attribute': + $attribute_taxonomy = $this->aggregations[ $label ]['attribute']; + + $attribute_term = get_term_by( 'slug', $item['key'], $attribute_taxonomy ); + + if ( ! $attribute_term ) { + continue 2; // switch() is considered a looping structure. + } + + $tax_query_var = $this->get_taxonomy_query_var( $attribute_taxonomy ); + + if ( ! $tax_query_var ) { + continue 2; + } + + // Figure out which terms are already selected for this attribute. + $existing_attribute_slugs = array(); + if ( ! empty( $query->tax_query ) && ! empty( $query->tax_query->queries ) && is_array( $query->tax_query->queries ) ) { + foreach ( $query->tax_query->queries as $tax_query ) { + if ( is_array( $tax_query ) && $attribute_taxonomy === $tax_query['taxonomy'] && + 'slug' === $tax_query['field'] && + is_array( $tax_query['terms'] ) ) { + $existing_attribute_slugs = array_merge( $existing_attribute_slugs, $tax_query['terms'] ); + } + } + } + + $query_vars = array( + $tax_query_var => implode( '+', array_merge( $existing_attribute_slugs, array( $attribute_term->slug ) ) ), + ); + + $name = $attribute_term->name; + + // Let's determine if this attribute is active or not. + if ( in_array( $item['key'], $existing_attribute_slugs, true ) ) { + $active = true; + + $slug_count = count( $existing_attribute_slugs ); + + if ( $slug_count > 1 ) { + $remove_url = Helper::add_query_arg( + $tax_query_var, + rawurlencode( implode( '+', array_diff( $existing_attribute_slugs, array( $item['key'] ) ) ) ) + ); + } else { + $remove_url = Helper::remove_query_arg( $tax_query_var ); + } + } + + break; + case 'post_type': $post_type = get_post_type_object( $item['key'] ); @@ -1670,57 +1725,6 @@ public function get_filters( ?WP_Query $query = null ) { break; - case 'product_attribute': - $attribute_taxonomy = $this->aggregations[ $label ]['attribute']; - - $term = get_term_by( 'slug', $item['key'], $attribute_taxonomy ); - - if ( ! $term ) { - continue 2; // switch() is considered a looping structure. - } - - $tax_query_var = $this->get_taxonomy_query_var( $attribute_taxonomy ); - - if ( ! $tax_query_var ) { - continue 2; - } - - // Figure out which terms are already selected for this attribute. - $existing_attribute_slugs = array(); - if ( ! empty( $query->tax_query ) && ! empty( $query->tax_query->queries ) && is_array( $query->tax_query->queries ) ) { - foreach ( $query->tax_query->queries as $tax_query ) { - if ( is_array( $tax_query ) && $attribute_taxonomy === $tax_query['taxonomy'] && - 'slug' === $tax_query['field'] && - is_array( $tax_query['terms'] ) ) { - $existing_attribute_slugs = array_merge( $existing_attribute_slugs, $tax_query['terms'] ); - } - } - } - - $query_vars = array( - $tax_query_var => implode( '+', array_merge( $existing_attribute_slugs, array( $term->slug ) ) ), - ); - - $name = $term->name; - - // Let's determine if this term is active or not. - if ( in_array( $item['key'], $existing_attribute_slugs, true ) ) { - $active = true; - - $slug_count = count( $existing_attribute_slugs ); - - if ( $slug_count > 1 ) { - $remove_url = Helper::add_query_arg( - $tax_query_var, - rawurlencode( implode( '+', array_diff( $existing_attribute_slugs, array( $item['key'] ) ) ) ) - ); - } else { - $remove_url = Helper::remove_query_arg( $tax_query_var ); - } - } - - break; - case 'date_histogram': $timestamp = $item['key'] / 1000; From 23b7f4099b4849cddbb7713dd580cf2a1a4c3bc1 Mon Sep 17 00:00:00 2001 From: Katja Paavola Date: Mon, 13 Oct 2025 20:03:07 +0100 Subject: [PATCH 05/11] Update explanatory text, hide field --- .../src/widgets/class-search-widget.php | 8 +++++-- .../widgets/css/search-widget-admin-ui.css | 23 ++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/projects/packages/search/src/widgets/class-search-widget.php b/projects/packages/search/src/widgets/class-search-widget.php index 0bf7a37027777..2ec1e70b4002f 100644 --- a/projects/packages/search/src/widgets/class-search-widget.php +++ b/projects/packages/search/src/widgets/class-search-widget.php @@ -1004,7 +1004,7 @@ public function render_widget_edit_filter( $filter, $is_template = false, $is_in @@ -1084,7 +1084,7 @@ class="widefat"

-

+

+

+ +

+

diff --git a/projects/packages/search/src/widgets/css/search-widget-admin-ui.css b/projects/packages/search/src/widgets/css/search-widget-admin-ui.css index c407b62254db1..ff82b4417a4c0 100644 --- a/projects/packages/search/src/widgets/css/search-widget-admin-ui.css +++ b/projects/packages/search/src/widgets/css/search-widget-admin-ui.css @@ -67,11 +67,32 @@ display: none; } -/* When product attribute is selected, hide taxonomy selector */ +/* When product attribute is selected, hide taxonomy selector, title + * field, and filter count */ .jetpack-search-filters-widget__filter.is-product_attribute .jetpack-search-filters-widget__taxonomy-select { display: none; } +.jetpack-search-filters-widget__filter.is-product_attribute .jetpack-search-filters-widget__title { + display: none; +} + +.jetpack-search-filters-widget__filter.is-product_attribute .jetpack-search-filters-widget__filter-count { + display: none; +} + +/* Hide product attribute info by default */ +.jetpack-search-filters-widget__product-attribute-info { + display: none; + letter-spacing: normal; + font-size: 0.9rem; +} + +/* Show product attribute info only when product attribute is selected */ +.jetpack-search-filters-widget__filter.is-product_attribute .jetpack-search-filters-widget__product-attribute-info { + display: block; +} + .jetpack-search-filters-widget.hide-post-types .jetpack-search-filters-widget__post-types-select { display: none; } From 42025401f078f9ec7dfd7284b909708201e2372c Mon Sep 17 00:00:00 2001 From: Katja Paavola Date: Tue, 14 Oct 2025 13:43:57 +0100 Subject: [PATCH 06/11] Switch to filter inclusion to protect against large sites with many PAs --- projects/packages/search/src/class-helper.php | 22 ++++-- .../src/widgets/class-search-widget.php | 71 +++++++++++++++---- .../widgets/css/search-widget-admin-ui.css | 40 ++++++++--- 3 files changed, 108 insertions(+), 25 deletions(-) diff --git a/projects/packages/search/src/class-helper.php b/projects/packages/search/src/class-helper.php index bc42ff7eea8be..262024e6d88b3 100644 --- a/projects/packages/search/src/class-helper.php +++ b/projects/packages/search/src/class-helper.php @@ -185,15 +185,29 @@ public static function get_filters_from_widgets( $allowed_widget_ids = null ) { // If this is a product_attribute filter with no specific attribute, expand it to all global attributes. if ( 'product_attribute' === $type && empty( $widget_filter['attribute'] ) ) { - if ( function_exists( 'wc_get_attribute_taxonomies' ) || function_exists( 'wc_attribute_taxonomy_name' ) ) { - $product_attributes = wc_get_attribute_taxonomies(); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. + if ( function_exists( 'wc_get_attribute_taxonomies' ) && function_exists( 'wc_attribute_taxonomy_name' ) ) { + $product_attributes = wc_get_attribute_taxonomies(); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. + $included_attributes = isset( $widget_filter['included_attributes'] ) ? (array) $widget_filter['included_attributes'] : array(); + + // If no attributes are explicitly included, show all attributes (backward compatibility). + // Also optimize by treating "all selected" the same as "none selected" to avoid O(n²) in_array() checks. + $show_all = empty( $included_attributes ) || count( $included_attributes ) === count( $product_attributes ); + foreach ( $product_attributes as $attribute ) { - $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. + $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. + + // Only include attributes that are in the included_attributes list (or show all if none specified). + if ( ! $show_all && ! in_array( $attribute_name, $included_attributes, true ) ) { + continue; + } + $key = sprintf( '%s_%d', $type, count( $filters ) ); $expanded_filter = $widget_filter; $expanded_filter['attribute'] = $attribute_name; $expanded_filter['name'] = $attribute->attribute_label; - $filters[ $key ] = $expanded_filter; + // Remove included_attributes from the expanded filter as it's no longer needed. + unset( $expanded_filter['included_attributes'] ); + $filters[ $key ] = $expanded_filter; } } } else { diff --git a/projects/packages/search/src/widgets/class-search-widget.php b/projects/packages/search/src/widgets/class-search-widget.php index 2ec1e70b4002f..65f9323273238 100644 --- a/projects/packages/search/src/widgets/class-search-widget.php +++ b/projects/packages/search/src/widgets/class-search-widget.php @@ -701,11 +701,18 @@ public function update( $new_instance, $old_instance ) { // phpcs:ignore Variabl ); break; case 'product_attribute': - $filters[] = array( - 'name' => sanitize_text_field( $new_instance['filter_name'][ $index ] ), - 'type' => 'product_attribute', - 'count' => $count, + $filter_data = array( + 'name' => sanitize_text_field( $new_instance['filter_name'][ $index ] ), + 'type' => 'product_attribute', ); + // Save included attributes if any are selected. + if ( isset( $new_instance[ 'included_attributes_' . $index ] ) && is_array( $new_instance[ 'included_attributes_' . $index ] ) ) { + $filter_data['included_attributes'] = array_map( 'sanitize_key', $new_instance[ 'included_attributes_' . $index ] ); + } + // For product attributes, we don't use the count field since we show all selected attributes. + // But we still need it in the array for backward compatibility with other filter types. + $filter_data['count'] = $count; + $filters[] = $filter_data; break; } } @@ -732,13 +739,17 @@ protected function maybe_reformat_widget( $widget_instance ) { } $instance = $widget_instance; - foreach ( $widget_instance['filters'] as $filter ) { + foreach ( $widget_instance['filters'] as $index => $filter ) { $instance['filter_type'][] = isset( $filter['type'] ) ? $filter['type'] : ''; $instance['taxonomy_type'][] = isset( $filter['taxonomy'] ) ? $filter['taxonomy'] : ''; $instance['filter_name'][] = isset( $filter['name'] ) ? $filter['name'] : ''; $instance['num_filters'][] = isset( $filter['count'] ) ? $filter['count'] : 5; $instance['date_histogram_field'][] = isset( $filter['field'] ) ? $filter['field'] : ''; $instance['date_histogram_interval'][] = isset( $filter['interval'] ) ? $filter['interval'] : ''; + // Handle included_attributes for product_attribute filters. + if ( isset( $filter['included_attributes'] ) && is_array( $filter['included_attributes'] ) ) { + $instance[ 'included_attributes_' . $index ] = $filter['included_attributes']; + } } unset( $instance['filters'] ); return $instance; @@ -843,9 +854,13 @@ class="widefat jetpack-search-filters-widget__sort-order"> render_widget_edit_filter( array(), true ); ?>
- - render_widget_edit_filter( $filter ); ?> - + render_widget_edit_filter( $filter, false, false, $filter_index ); + ++$filter_index; + endforeach; + ?>

@@ -958,9 +973,10 @@ private function render_widget_option_selected( $name, $value, $compare, $is_tem * @param array $filter The filter to render. * @param bool $is_template Whether this is for an Underscore template or not. * @param bool $is_instant_search Whether this site enables Instant Search or not. + * @param int $filter_index The index of this filter in the filters array. * @since 5.7.0 */ - public function render_widget_edit_filter( $filter, $is_template = false, $is_instant_search = false ) { + public function render_widget_edit_filter( $filter, $is_template = false, $is_instant_search = false, $filter_index = 0 ) { $args = wp_parse_args( $filter, array( @@ -1084,6 +1100,39 @@ class="widefat"

+
+ +

+ +

+
+ + attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction We're checking existence. + $is_included = in_array( $attribute_name, $included_attributes, true ); + ?> + + +
+ +
+

-

- -

-

diff --git a/projects/packages/search/src/widgets/css/search-widget-admin-ui.css b/projects/packages/search/src/widgets/css/search-widget-admin-ui.css index ff82b4417a4c0..1f47c002285c8 100644 --- a/projects/packages/search/src/widgets/css/search-widget-admin-ui.css +++ b/projects/packages/search/src/widgets/css/search-widget-admin-ui.css @@ -16,6 +16,13 @@ .jetpack-search-filters-widget__controls .delete { color: #d63638; + font-size: 1rem; + text-decoration: none; + cursor: pointer; +} + +.jetpack-search-filters-widget__controls .delete:hover { + text-decoration: underline; } .jetpack-search-filters-widget.hide-filters .jetpack-search-filters-widget__filter { @@ -67,8 +74,8 @@ display: none; } -/* When product attribute is selected, hide taxonomy selector, title - * field, and filter count */ +/* When product attribute is selected, hide taxonomy select, + title, and filter count */ .jetpack-search-filters-widget__filter.is-product_attribute .jetpack-search-filters-widget__taxonomy-select { display: none; } @@ -81,18 +88,35 @@ display: none; } -/* Hide product attribute info by default */ -.jetpack-search-filters-widget__product-attribute-info { +/* Hide product attribute inclusions by default */ +.jetpack-search-filters-widget__product-attribute-inclusions { display: none; - letter-spacing: normal; - font-size: 0.9rem; } -/* Show product attribute info only when product attribute is selected */ -.jetpack-search-filters-widget__filter.is-product_attribute .jetpack-search-filters-widget__product-attribute-info { +/* Show product attribute inclusions only when product attribute is selected */ +.jetpack-search-filters-widget__filter.is-product_attribute .jetpack-search-filters-widget__product-attribute-inclusions { display: block; } +/* Style the attribute checkboxes in a three-column layout */ +.jetpack-search-filters-widget__attribute-checkboxes { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 4px 12px; + margin-bottom: 12px; +} + +.jetpack-search-filters-widget__attribute-checkbox { + display: flex; + align-items: center; + margin: 0; + font-size: 0.9rem; +} + +.jetpack-search-filters-widget__attribute-checkbox input[type="checkbox"] { + margin: 0 6px 0 0; +} + .jetpack-search-filters-widget.hide-post-types .jetpack-search-filters-widget__post-types-select { display: none; } From 26468c4846532f570185095de7ceb83fcc1981b1 Mon Sep 17 00:00:00 2001 From: Katja Paavola Date: Tue, 14 Oct 2025 18:09:01 +0100 Subject: [PATCH 07/11] Cleanup - Refactor class-search-widget.php to remove unnecessary PHP tags - Update class-search-widget.php product_attribute case to move where 'count' attribute is added - Refactor get_filters_from_widgets() to separate concerns - Fix product_attribute case in class-classic-search.php to prevent slugs from being re-added to an array unnecessarily --- projects/packages/search/src/class-helper.php | 77 ++++++++++--------- .../classic-search/class-classic-search.php | 18 +++-- .../src/widgets/class-search-widget.php | 67 ++++++++-------- 3 files changed, 88 insertions(+), 74 deletions(-) diff --git a/projects/packages/search/src/class-helper.php b/projects/packages/search/src/class-helper.php index 262024e6d88b3..318c8d0d93bd1 100644 --- a/projects/packages/search/src/class-helper.php +++ b/projects/packages/search/src/class-helper.php @@ -185,31 +185,7 @@ public static function get_filters_from_widgets( $allowed_widget_ids = null ) { // If this is a product_attribute filter with no specific attribute, expand it to all global attributes. if ( 'product_attribute' === $type && empty( $widget_filter['attribute'] ) ) { - if ( function_exists( 'wc_get_attribute_taxonomies' ) && function_exists( 'wc_attribute_taxonomy_name' ) ) { - $product_attributes = wc_get_attribute_taxonomies(); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. - $included_attributes = isset( $widget_filter['included_attributes'] ) ? (array) $widget_filter['included_attributes'] : array(); - - // If no attributes are explicitly included, show all attributes (backward compatibility). - // Also optimize by treating "all selected" the same as "none selected" to avoid O(n²) in_array() checks. - $show_all = empty( $included_attributes ) || count( $included_attributes ) === count( $product_attributes ); - - foreach ( $product_attributes as $attribute ) { - $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. - - // Only include attributes that are in the included_attributes list (or show all if none specified). - if ( ! $show_all && ! in_array( $attribute_name, $included_attributes, true ) ) { - continue; - } - - $key = sprintf( '%s_%d', $type, count( $filters ) ); - $expanded_filter = $widget_filter; - $expanded_filter['attribute'] = $attribute_name; - $expanded_filter['name'] = $attribute->attribute_label; - // Remove included_attributes from the expanded filter as it's no longer needed. - unset( $expanded_filter['included_attributes'] ); - $filters[ $key ] = $expanded_filter; - } - } + $filters = self::expand_product_attribute_filters( $widget_filter, $filters ); } else { $key = sprintf( '%s_%d', $type, count( $filters ) ); $filters[ $key ] = $widget_filter; @@ -220,6 +196,45 @@ public static function get_filters_from_widgets( $allowed_widget_ids = null ) { return $filters; } + /** + * Expands a product_attribute filter into individual filters for each attribute. + * + * @since 5.8.0 + * + * @param array $widget_filter The filter configuration. + * @param array $filters The existing filters array. + * @return array The filters array with expanded product attribute filters. + */ + private static function expand_product_attribute_filters( $widget_filter, $filters ) { + if ( ! function_exists( 'wc_get_attribute_taxonomies' ) || ! function_exists( 'wc_attribute_taxonomy_name' ) ) { + return $filters; + } + + $product_attributes = wc_get_attribute_taxonomies(); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. + $included_attributes = isset( $widget_filter['included_attributes'] ) ? (array) $widget_filter['included_attributes'] : array(); + + // If no attributes are explicitly included, show all attributes (backward compatibility). + // Also optimize by treating "all selected" the same as "none selected" to avoid O(n²) in_array() checks. + $show_all = empty( $included_attributes ) || count( $included_attributes ) === count( $product_attributes ); + + foreach ( $product_attributes as $attribute ) { + $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. + + if ( ! $show_all && ! in_array( $attribute_name, $included_attributes, true ) ) { + continue; + } + + $key = sprintf( 'product_attribute_%d', count( $filters ) ); + $expanded_filter = $widget_filter; + $expanded_filter['attribute'] = $attribute_name; + $expanded_filter['name'] = $attribute->attribute_label; + unset( $expanded_filter['included_attributes'] ); + $filters[ $key ] = $expanded_filter; + } + + return $filters; + } + /** * Get the localized default label for a date filter. * @@ -313,17 +328,9 @@ public static function generate_widget_filter_name( $widget_filter ) { break; case 'product_attribute': - if ( ! empty( $widget_filter['attribute'] ) ) { - $attribute_taxonomy = get_taxonomy( $widget_filter['attribute'] ); - if ( $attribute_taxonomy && isset( $attribute_taxonomy->label ) ) { - $name = $attribute_taxonomy->label; - } else { - $name = _x( 'Product Attributes', 'label for filtering posts', 'jetpack-search-pkg' ); - } - } else { - $name = _x( 'Product Attributes', 'label for filtering posts', 'jetpack-search-pkg' ); - } + $name = _x( 'Product Attributes', 'label for filtering posts', 'jetpack-search-pkg' ); break; + } return $name; diff --git a/projects/packages/search/src/classic-search/class-classic-search.php b/projects/packages/search/src/classic-search/class-classic-search.php index 4f183e2991122..70af3539dfd9e 100644 --- a/projects/packages/search/src/classic-search/class-classic-search.php +++ b/projects/packages/search/src/classic-search/class-classic-search.php @@ -1639,16 +1639,19 @@ public function get_filters( ?WP_Query $query = null ) { } } - $query_vars = array( - $tax_query_var => implode( '+', array_merge( $existing_attribute_slugs, array( $attribute_term->slug ) ) ), - ); - $name = $attribute_term->name; // Let's determine if this attribute is active or not. - if ( in_array( $item['key'], $existing_attribute_slugs, true ) ) { + $is_active = in_array( $item['key'], $existing_attribute_slugs, true ); + + if ( $is_active ) { $active = true; + // For active items, maintain the current state (don't redundantly add the slug again). + $query_vars = array( + $tax_query_var => implode( '+', $existing_attribute_slugs ), + ); + $slug_count = count( $existing_attribute_slugs ); if ( $slug_count > 1 ) { @@ -1659,6 +1662,11 @@ public function get_filters( ?WP_Query $query = null ) { } else { $remove_url = Helper::remove_query_arg( $tax_query_var ); } + } else { + // For inactive items, add this slug to the existing ones. + $query_vars = array( + $tax_query_var => implode( '+', array_merge( $existing_attribute_slugs, array( $attribute_term->slug ) ) ), + ); } break; diff --git a/projects/packages/search/src/widgets/class-search-widget.php b/projects/packages/search/src/widgets/class-search-widget.php index 65f9323273238..b52b34c665406 100644 --- a/projects/packages/search/src/widgets/class-search-widget.php +++ b/projects/packages/search/src/widgets/class-search-widget.php @@ -702,17 +702,15 @@ public function update( $new_instance, $old_instance ) { // phpcs:ignore Variabl break; case 'product_attribute': $filter_data = array( - 'name' => sanitize_text_field( $new_instance['filter_name'][ $index ] ), - 'type' => 'product_attribute', + 'name' => sanitize_text_field( $new_instance['filter_name'][ $index ] ), + 'type' => 'product_attribute', + 'count' => $count, ); // Save included attributes if any are selected. if ( isset( $new_instance[ 'included_attributes_' . $index ] ) && is_array( $new_instance[ 'included_attributes_' . $index ] ) ) { $filter_data['included_attributes'] = array_map( 'sanitize_key', $new_instance[ 'included_attributes_' . $index ] ); } - // For product attributes, we don't use the count field since we show all selected attributes. - // But we still need it in the array for backward compatibility with other filter types. - $filter_data['count'] = $count; - $filters[] = $filter_data; + $filters[] = $filter_data; break; } } @@ -1101,36 +1099,37 @@ class="widefat"

- -

- -

-
- - attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction We're checking existence. - $is_included = in_array( $attribute_name, $included_attributes, true ); - ?> - - -
+ +

+ +

+
attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction + $is_included = in_array( $attribute_name, $included_attributes, true ); + ?> + + +
+ + } + ?>

From fcc02faac2ec2d24bfdb937da76f2e8289ab8683 Mon Sep 17 00:00:00 2001 From: Katja Paavola Date: Tue, 14 Oct 2025 19:01:49 +0100 Subject: [PATCH 08/11] Replace eval() with proper WooCommerce mock file - Create tests/php/woocommerce-mocks.php with mock functions - Remove eval() calls from product attribute tests - Mock functions defined in global namespace for proper testing - Test expansion of generic product_attribute filters into specific attributes - Test preservation of specific product_attribute filters (no expansion) - Test included_attributes filtering to limit expansion - Verify property inheritance (widget_id, count, name) - Add helper methods for product attribute filter data providers --- .../src/instant-search/lib/test/api.test.js | 92 ++++++++- .../instant-search/lib/test/filters.test.js | 40 ++++ .../search/tests/php/Helpers_Test.php | 174 ++++++++++++++++++ .../search/tests/php/woocommerce-mocks.php | 42 +++++ 4 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 projects/packages/search/tests/php/woocommerce-mocks.php diff --git a/projects/packages/search/src/instant-search/lib/test/api.test.js b/projects/packages/search/src/instant-search/lib/test/api.test.js index f50ae75b867c8..fa9ec02e0ba74 100644 --- a/projects/packages/search/src/instant-search/lib/test/api.test.js +++ b/projects/packages/search/src/instant-search/lib/test/api.test.js @@ -1,7 +1,97 @@ /** * @jest-environment jsdom */ -import { generateDateRangeFilter, setDocumentCountsToZero } from '../api'; +import { buildFilterAggregations, generateDateRangeFilter, setDocumentCountsToZero } from '../api'; + +describe( 'buildFilterAggregations', () => { + test( 'generates aggregations for product_attribute filters', () => { + const widgets = [ + { + filters: [ + { + type: 'product_attribute', + attribute: 'pa_color', + count: 10, + filter_id: 'product_attribute_color', + }, + ], + }, + ]; + expect( buildFilterAggregations( widgets ) ).toEqual( { + product_attribute_color: { + terms: { + field: 'taxonomy.pa_color.slug_slash_name', + size: 10, + }, + }, + } ); + } ); + + test( 'generates aggregations for multiple product_attribute filters', () => { + const widgets = [ + { + filters: [ + { + type: 'product_attribute', + attribute: 'pa_color', + count: 10, + filter_id: 'product_attribute_color', + }, + { + type: 'product_attribute', + attribute: 'pa_size', + count: 5, + filter_id: 'product_attribute_size', + }, + ], + }, + ]; + expect( buildFilterAggregations( widgets ) ).toEqual( { + product_attribute_color: { + terms: { + field: 'taxonomy.pa_color.slug_slash_name', + size: 10, + }, + }, + product_attribute_size: { + terms: { + field: 'taxonomy.pa_size.slug_slash_name', + size: 5, + }, + }, + } ); + } ); + + test( 'generates aggregations for mixed filter types including product_attribute', () => { + const widgets = [ + { + filters: [ + { + type: 'taxonomy', + taxonomy: 'category', + count: 5, + filter_id: 'category_filter', + }, + { + type: 'product_attribute', + attribute: 'pa_color', + count: 10, + filter_id: 'product_attribute_color', + }, + ], + }, + ]; + const result = buildFilterAggregations( widgets ); + expect( result ).toHaveProperty( 'category_filter' ); + expect( result ).toHaveProperty( 'product_attribute_color' ); + expect( result.product_attribute_color ).toEqual( { + terms: { + field: 'taxonomy.pa_color.slug_slash_name', + size: 10, + }, + } ); + } ); +} ); describe( 'generateDateRangeFilter', () => { test( 'generates correct ranges for yearly date ranges', () => { diff --git a/projects/packages/search/src/instant-search/lib/test/filters.test.js b/projects/packages/search/src/instant-search/lib/test/filters.test.js index 8c2fe18fa90a5..05b5f1bc93373 100644 --- a/projects/packages/search/src/instant-search/lib/test/filters.test.js +++ b/projects/packages/search/src/instant-search/lib/test/filters.test.js @@ -44,6 +44,32 @@ describe( 'getFilterKeys', () => { 'subject', ] ); } ); + + test( 'includes product attributes from widget configurations without duplicates', () => { + const widgets = [ + { filters: [ { type: 'product_attribute', attribute: 'pa_color' } ] }, + { filters: [ { type: 'product_attribute', attribute: 'pa_size' } ] }, + { filters: [ { type: 'product_attribute', attribute: 'pa_color' } ] }, + ]; + expect( getFilterKeys( widgets, [] ) ).toEqual( [ + 'blog_ids', + 'authors', + 'post_types', + 'category', + 'post_format', + 'post_tag', + 'month_post_date', + 'month_post_date_gmt', + 'month_post_modified', + 'month_post_modified_gmt', + 'year_post_date', + 'year_post_date_gmt', + 'year_post_modified', + 'year_post_modified_gmt', + 'pa_color', + 'pa_size', + ] ); + } ); } ); describe( 'getSelectableFilterKeys', () => { @@ -174,4 +200,18 @@ describe( 'mapFilterKeyToFilter', () => { taxonomy: 'arcade_reviews', } ); } ); + test( 'handles product attribute filter keys', () => { + expect( mapFilterKeyToFilter( 'pa_color' ) ).toEqual( { + type: 'product_attribute', + attribute: 'pa_color', + } ); + expect( mapFilterKeyToFilter( 'pa_size' ) ).toEqual( { + type: 'product_attribute', + attribute: 'pa_size', + } ); + expect( mapFilterKeyToFilter( 'pa_material' ) ).toEqual( { + type: 'product_attribute', + attribute: 'pa_material', + } ); + } ); } ); diff --git a/projects/packages/search/tests/php/Helpers_Test.php b/projects/packages/search/tests/php/Helpers_Test.php index 6c67a63040773..5f9014e83b503 100644 --- a/projects/packages/search/tests/php/Helpers_Test.php +++ b/projects/packages/search/tests/php/Helpers_Test.php @@ -13,6 +13,7 @@ require_once __DIR__ . '/class-test-helpers-customize.php'; require_once __DIR__ . '/class-test-helpers-query.php'; +require_once __DIR__ . '/woocommerce-mocks.php'; /** * Helpers for Classic and Instant Search tests @@ -1637,4 +1638,177 @@ public static function get_date_histogram_posts_modified_by_year_gmt_filter() { 'count' => 10, ); } + + /** + * Data provider for product attribute filter. + */ + public static function get_product_attribute_filter() { + return array( + 'type' => 'product_attribute', + 'name' => 'Product Attributes', + 'count' => 10, + ); + } + + /** + * Data provider for product attribute filter with specific attribute. + */ + public static function get_product_attribute_filter_with_attribute() { + return array( + 'type' => 'product_attribute', + 'name' => 'Color', + 'attribute' => 'pa_color', + 'count' => 10, + ); + } + + /** + * Test case for get_filters_from_widgets with product_attribute filters + */ + public function test_get_filters_from_widgets_with_product_attributes() { + $raw_option = static::get_sample_widgets_option(); + // Add a product_attribute filter without specific attribute (should expand). + $raw_option[22]['filters'][] = array( + 'type' => 'product_attribute', + 'name' => 'Product Attributes', + 'count' => 10, + ); + + // Add a product_attribute filter with specific attribute (should not expand). + $raw_option[22]['filters'][] = array( + 'type' => 'product_attribute', + 'name' => 'Color', + 'attribute' => 'pa_color', + 'count' => 5, + ); + + update_option( Helper::get_widget_option_name(), $raw_option ); + $this->register_fake_widgets(); + + $filters = Helper::get_filters_from_widgets(); + + // Collect product_attribute filters. + $product_attributes = array_filter( + $filters, + function ( $filter ) { + return isset( $filter['type'] ) && $filter['type'] === 'product_attribute'; + } + ); + + // Should have 4 product_attribute filters total (3 expanded + 1 specific) + $this->assertCount( 4, $product_attributes, 'Should have 4 product attribute filters' ); + + // Find specific attributes and verify their properties + $found_pa_color = 0; + $found_pa_size = 0; + $found_pa_material = 0; + $found_pa_color_count_5 = false; + $found_expanded_color = false; + + foreach ( $product_attributes as $filter ) { + $this->assertArrayHasKey( 'attribute', $filter, 'Filter should have attribute key' ); + $this->assertArrayHasKey( 'widget_id', $filter, 'Filter should have widget_id' ); + $this->assertSame( 'jetpack-search-filters-22', $filter['widget_id'], 'widget_id should be inherited' ); + + if ( $filter['attribute'] === 'pa_color' ) { + ++$found_pa_color; + if ( isset( $filter['count'] ) && $filter['count'] === 5 ) { + $found_pa_color_count_5 = true; + // This is the specific filter, name should be preserved + $this->assertSame( 'Color', $filter['name'], 'Specific filter should preserve its name' ); + } else { + // This is the expanded filter + $found_expanded_color = true; + $this->assertSame( 'Color', $filter['name'], 'Expanded filter should use attribute label' ); + $this->assertSame( 10, $filter['count'], 'Expanded filter should inherit count from parent' ); + } + } + if ( $filter['attribute'] === 'pa_size' ) { + ++$found_pa_size; + $this->assertSame( 'Size', $filter['name'], 'Expanded filter should use attribute label' ); + $this->assertSame( 10, $filter['count'], 'Expanded filter should inherit count from parent' ); + } + if ( $filter['attribute'] === 'pa_material' ) { + ++$found_pa_material; + $this->assertSame( 'Material', $filter['name'], 'Expanded filter should use attribute label' ); + $this->assertSame( 10, $filter['count'], 'Expanded filter should inherit count from parent' ); + } + } + + $this->assertSame( 2, $found_pa_color, 'Should have 2 pa_color filters (1 expanded, 1 specific)' ); + $this->assertSame( 1, $found_pa_size, 'Should have 1 pa_size filter (expanded)' ); + $this->assertSame( 1, $found_pa_material, 'Should have 1 pa_material filter (expanded)' ); + $this->assertTrue( $found_pa_color_count_5, 'Should have pa_color filter with count=5' ); + $this->assertTrue( $found_expanded_color, 'Should have expanded pa_color filter with count=10' ); + } + + /** + * Test case for get_filters_from_widgets with product_attribute and included_attributes + */ + public function test_get_filters_from_widgets_with_product_attributes_inclusion() { + // WooCommerce functions are already mocked in the previous test + + $raw_option = static::get_sample_widgets_option(); + // Add a product_attribute filter with specific included attributes. + $raw_option[22]['filters'][] = array( + 'type' => 'product_attribute', + 'name' => 'Product Attributes', + 'count' => 10, + 'included_attributes' => array( 'pa_color', 'pa_size' ), + ); + + update_option( Helper::get_widget_option_name(), $raw_option ); + $this->register_fake_widgets(); + + $filters = Helper::get_filters_from_widgets(); + + // Collect product_attribute filters. + $product_attributes = array_filter( + $filters, + function ( $filter ) { + return isset( $filter['type'] ) && $filter['type'] === 'product_attribute'; + } + ); + + // Should have 2 product_attribute filters (color and size from included_attributes) + $this->assertCount( 2, $product_attributes, 'Should have 2 product attribute filters' ); + + // Check that only included attributes are present and verify all properties + $found_pa_color = false; + $found_pa_size = false; + $found_pa_material = false; + + foreach ( $product_attributes as $filter ) { + // Verify required keys exist + $this->assertArrayHasKey( 'attribute', $filter, 'Filter should have attribute key' ); + $this->assertArrayHasKey( 'widget_id', $filter, 'Filter should have widget_id' ); + $this->assertArrayHasKey( 'count', $filter, 'Filter should have count' ); + $this->assertArrayHasKey( 'name', $filter, 'Filter should have name' ); + + // Verify inherited properties + $this->assertSame( 'jetpack-search-filters-22', $filter['widget_id'], 'widget_id should be inherited' ); + $this->assertSame( 10, $filter['count'], 'count should be inherited from parent filter' ); + + // Verify included_attributes was removed from expanded filters + $this->assertArrayNotHasKey( 'included_attributes', $filter, 'included_attributes should be removed from expanded filters' ); + + // Track which attributes were found + if ( $filter['attribute'] === 'pa_color' ) { + $found_pa_color = true; + $this->assertSame( 'Color', $filter['name'], 'pa_color filter should use attribute label as name' ); + } + if ( $filter['attribute'] === 'pa_size' ) { + $found_pa_size = true; + $this->assertSame( 'Size', $filter['name'], 'pa_size filter should use attribute label as name' ); + } + if ( $filter['attribute'] === 'pa_material' ) { + $found_pa_material = true; + } + } + + // Verify the inclusion filtering worked correctly + $this->assertTrue( $found_pa_color, 'Should have pa_color attribute (was in included_attributes)' ); + $this->assertTrue( $found_pa_size, 'Should have pa_size attribute (was in included_attributes)' ); + $this->assertFalse( $found_pa_material, 'Should NOT have pa_material attribute (was NOT in included_attributes)' ); + } } diff --git a/projects/packages/search/tests/php/woocommerce-mocks.php b/projects/packages/search/tests/php/woocommerce-mocks.php new file mode 100644 index 0000000000000..c1e3da6829f76 --- /dev/null +++ b/projects/packages/search/tests/php/woocommerce-mocks.php @@ -0,0 +1,42 @@ + 'color', + 'attribute_label' => 'Color', + ), + (object) array( + 'attribute_name' => 'size', + 'attribute_label' => 'Size', + ), + (object) array( + 'attribute_name' => 'material', + 'attribute_label' => 'Material', + ), + ); + } +} + +if ( ! function_exists( 'wc_attribute_taxonomy_name' ) ) { + /** + * Mock WooCommerce function to get taxonomy name from attribute name. + * + * @param string $attribute_name The attribute name. + * @return string The taxonomy name with 'pa_' prefix. + */ + function wc_attribute_taxonomy_name( $attribute_name ) { + return 'pa_' . $attribute_name; + } +} From e1b7fd7a60fe4edc885772af75b085d4ae9ac46e Mon Sep 17 00:00:00 2001 From: Katja Paavola Date: Tue, 14 Oct 2025 19:20:38 +0100 Subject: [PATCH 09/11] Remove PHAN exclusion as no longer necessary given mocks added for tests --- projects/packages/search/src/class-helper.php | 4 ++-- .../search/src/classic-search/class-classic-search.php | 4 ++-- projects/packages/search/src/widgets/class-search-widget.php | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/projects/packages/search/src/class-helper.php b/projects/packages/search/src/class-helper.php index 318c8d0d93bd1..c199a1ea8861a 100644 --- a/projects/packages/search/src/class-helper.php +++ b/projects/packages/search/src/class-helper.php @@ -210,7 +210,7 @@ private static function expand_product_attribute_filters( $widget_filter, $filte return $filters; } - $product_attributes = wc_get_attribute_taxonomies(); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. + $product_attributes = wc_get_attribute_taxonomies(); $included_attributes = isset( $widget_filter['included_attributes'] ) ? (array) $widget_filter['included_attributes'] : array(); // If no attributes are explicitly included, show all attributes (backward compatibility). @@ -218,7 +218,7 @@ private static function expand_product_attribute_filters( $widget_filter, $filte $show_all = empty( $included_attributes ) || count( $included_attributes ) === count( $product_attributes ); foreach ( $product_attributes as $attribute ) { - $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. + $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); if ( ! $show_all && ! in_array( $attribute_name, $included_attributes, true ) ) { continue; diff --git a/projects/packages/search/src/classic-search/class-classic-search.php b/projects/packages/search/src/classic-search/class-classic-search.php index 70af3539dfd9e..ee307621afd71 100644 --- a/projects/packages/search/src/classic-search/class-classic-search.php +++ b/projects/packages/search/src/classic-search/class-classic-search.php @@ -1366,14 +1366,14 @@ public function add_product_attribute_aggregation_to_es_query_builder( array $ag return; } - $product_attributes = wc_get_attribute_taxonomies(); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. + $product_attributes = wc_get_attribute_taxonomies(); if ( empty( $product_attributes ) ) { return; } foreach ( $product_attributes as $attribute ) { - $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction We're checking for the existence of this function. + $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); $agg_label = $label . '_' . $attribute_name; $this->build_product_attribute_agg( $attribute_name, $aggregation['count'], $agg_label, $builder ); diff --git a/projects/packages/search/src/widgets/class-search-widget.php b/projects/packages/search/src/widgets/class-search-widget.php index b52b34c665406..0fc93d3e6479d 100644 --- a/projects/packages/search/src/widgets/class-search-widget.php +++ b/projects/packages/search/src/widgets/class-search-widget.php @@ -1101,7 +1101,7 @@ class="widefat"

attribute_name ); // @phan-suppress-current-line PhanUndeclaredFunction + $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name ); $is_included = in_array( $attribute_name, $included_attributes, true ); ?>

From edb5c66a5d01de3cd43fdab9e58473e796a26763 Mon Sep 17 00:00:00 2001 From: Katja Paavola Date: Wed, 22 Oct 2025 17:15:57 +0100 Subject: [PATCH 11/11] Update comments to keep PHAN happy --- .../src/widgets/class-search-widget.php | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/projects/packages/search/src/widgets/class-search-widget.php b/projects/packages/search/src/widgets/class-search-widget.php index 621f499684604..148e3d371c78f 100644 --- a/projects/packages/search/src/widgets/class-search-widget.php +++ b/projects/packages/search/src/widgets/class-search-widget.php @@ -700,18 +700,21 @@ public function update( $new_instance, $old_instance ) { // phpcs:ignore Variabl 'interval' => sanitize_key( $new_instance['date_histogram_interval'][ $index ] ), ); break; - case 'product_attribute': - $filter_data = array( - 'name' => sanitize_text_field( $new_instance['filter_name'][ $index ] ), - 'type' => 'product_attribute', - 'count' => $count, - ); - // Save included attributes if any are selected. - if ( isset( $new_instance[ 'included_attributes_' . $index ] ) && is_array( $new_instance[ 'included_attributes_' . $index ] ) ) { - $filter_data['included_attributes'] = array_map( 'sanitize_key', $new_instance[ 'included_attributes_' . $index ] ); - } - $filters[] = $filter_data; - break; + // phpcs:disable Squiz.PHP.CommentedOutCode.Found + // TODO: Uncomment when Search rebuild is complete (search for: product_attribute filter). + // case 'product_attribute': + // $filter_data = array( + // 'name' => sanitize_text_field( $new_instance['filter_name'][ $index ] ), + // 'type' => 'product_attribute', + // 'count' => $count, + // ); + // Save included attributes if any are selected. + // if ( isset( $new_instance[ 'included_attributes_' . $index ] ) && is_array( $new_instance[ 'included_attributes_' . $index ] ) ) { + // $filter_data['included_attributes'] = array_map( 'sanitize_key', $new_instance[ 'included_attributes_' . $index ] ); + // } + // $filters[] = $filter_data; + // break. + // phpcs:enable Squiz.PHP.CommentedOutCode.Found } } } @@ -1017,7 +1020,7 @@ public function render_widget_edit_filter( $filter, $is_template = false, $is_in -