Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Instant Search: Add global WooCommerce Product Attributes as filter options.
53 changes: 51 additions & 2 deletions projects/packages/search/src/class-helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
Expand Down
134 changes: 134 additions & 0 deletions projects/packages/search/src/classic-search/class-classic-search.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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'] );

Expand Down
17 changes: 14 additions & 3 deletions projects/packages/search/src/inline-search/class-inline-search.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 (
<div>
<input
checked={ this.isChecked( slug ) }
disabled={ ! this.isChecked( slug ) && count === 0 }
id={ `${ this.idPrefix }-product-attributes-${ slug }` }
name={ slug }
onChange={ this.toggleFilter }
type="checkbox"
className="jetpack-instant-search__search-filter-list-input"
/>

<label
htmlFor={ `${ this.idPrefix }-product-attributes-${ slug }` }
className="jetpack-instant-search__search-filter-list-label"
>
{ strip( name ) } ({ count })
</label>
</div>
);
};

renderGroup = group => {
return (
<div className="jetpack-instant-search__search-filter-group-item">
Expand Down Expand Up @@ -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 );
}
Expand Down Expand Up @@ -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() }
</div>
) }
</div>
Expand Down
4 changes: 4 additions & 0 deletions projects/packages/search/src/instant-search/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
}
Expand Down
18 changes: 16 additions & 2 deletions projects/packages/search/src/instant-search/lib/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -183,6 +190,11 @@ export function mapFilterKeyToFilter( filterKey ) {
return {
type: 'group',
};
} else if ( filterKey.startsWith( 'pa_' ) ) {
return {
type: 'product_attribute',
attribute: filterKey,
};
}

return {
Expand All @@ -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';
}
Expand Down
Loading
Loading