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"
-
+
+
+