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/packages/search/src/class-helper.php b/projects/packages/search/src/class-helper.php index 3fc06fbc905a9..c199a1ea8861a 100644 --- a/projects/packages/search/src/class-helper.php +++ b/projects/packages/search/src/class-helper.php @@ -182,15 +182,59 @@ 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'] ) ) { + $filters = self::expand_product_attribute_filters( $widget_filter, $filters ); + } else { + $key = sprintf( '%s_%d', $type, count( $filters ) ); + $filters[ $key ] = $widget_filter; + } } } 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(); + $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 ); + + 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. * @@ -282,6 +326,11 @@ public static function generate_widget_filter_name( $widget_filter ) { $name = $tax->labels->name; } break; + + case 'product_attribute': + $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..ee307621afd71 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,72 @@ 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 ) { + // 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; + } + + if ( ! function_exists( 'wc_get_attribute_taxonomies' ) || ! function_exists( 'wc_attribute_taxonomy_name' ) ) { + 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 ); + $agg_label = $label . '_' . $attribute_name; + + $this->build_product_attribute_agg( $attribute_name, $aggregation['count'], $agg_label, $builder ); + + // 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. * @@ -1455,6 +1526,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(); @@ -1537,6 +1612,65 @@ 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'] ); + } + } + } + + $name = $attribute_term->name; + + // Let's determine if this attribute is active or not. + $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 ) { + $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 ); + } + } 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; + case 'post_type': $post_type = get_post_type_object( $item['key'] ); 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; } } 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/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/src/widgets/class-search-widget.php b/projects/packages/search/src/widgets/class-search-widget.php index 54d30460935a2..0fc93d3e6479d 100644 --- a/projects/packages/search/src/widgets/class-search-widget.php +++ b/projects/packages/search/src/widgets/class-search-widget.php @@ -700,6 +700,18 @@ 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; } } } @@ -725,13 +737,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; @@ -836,9 +852,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; + ?>

@@ -951,9 +971,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( @@ -996,6 +1017,9 @@ public function render_widget_edit_filter( $filter, $is_template = false, $is_in +

@@ -1074,7 +1098,41 @@ class="widefat"

-

+

+ +

+ +

+
+ attribute_name ); + $is_included = in_array( $attribute_name, $included_attributes, true ); + ?> + + +
+ +
+ +