diff --git a/assets/js/base/components/filter-placeholder/style.scss b/assets/js/base/components/filter-placeholder/style.scss
index b667fe5ecdd..3d92bb02b96 100644
--- a/assets/js/base/components/filter-placeholder/style.scss
+++ b/assets/js/base/components/filter-placeholder/style.scss
@@ -15,7 +15,8 @@
.wc-block-stock-filter__title,
.wc-block-price-filter__title,
.wc-block-active-filters__title,
- .wc-block-attribute-filter__title {
+ .wc-block-attribute-filter__title,
+ .wc-block-rating-filter__title {
margin: 0;
height: 1em;
}
diff --git a/assets/js/base/components/product-list/product-list.tsx b/assets/js/base/components/product-list/product-list.tsx
index 00133d8ede1..0adcfaf9084 100644
--- a/assets/js/base/components/product-list/product-list.tsx
+++ b/assets/js/base/components/product-list/product-list.tsx
@@ -132,6 +132,11 @@ const ProductList = ( {
'stock_status',
[]
);
+ const [ productRating, setProductRating ] = useQueryStateByKey(
+ 'rating',
+ []
+ );
+
const [ minPrice, setMinPrice ] = useQueryStateByKey( 'min_price' );
const [ maxPrice, setMaxPrice ] = useQueryStateByKey( 'max_price' );
@@ -215,6 +220,7 @@ const ProductList = ( {
const hasFilters =
productAttributes.length > 0 ||
productStockStatus.length > 0 ||
+ productRating.length > 0 ||
Number.isFinite( minPrice ) ||
Number.isFinite( maxPrice );
@@ -231,6 +237,7 @@ const ProductList = ( {
resetCallback={ () => {
setProductAttributes( [] );
setProductStockStatus( [] );
+ setProductRating( [] );
setMinPrice( null );
setMaxPrice( null );
} }
diff --git a/assets/js/base/components/product-rating/index.tsx b/assets/js/base/components/product-rating/index.tsx
new file mode 100644
index 00000000000..13f52da71a9
--- /dev/null
+++ b/assets/js/base/components/product-rating/index.tsx
@@ -0,0 +1,64 @@
+/**
+ * External dependencies
+ */
+import classNames from 'classnames';
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+const Rating = ( {
+ className,
+ key,
+ rating,
+ ratedProductsCount,
+}: RatingProps ): JSX.Element => {
+ const ratingClassName = classNames(
+ 'wc-block-components-product-rating',
+ className
+ );
+
+ const starStyle = {
+ width: ( rating / 5 ) * 100 + '%',
+ };
+
+ const ratingText = sprintf(
+ /* translators: %f is referring to the average rating value */
+ __( 'Rated %f out of 5', 'woo-gutenberg-products-block' ),
+ rating
+ );
+
+ const ratingHTML = {
+ __html: sprintf(
+ /* translators: %f is referring to the rating value */
+ __( 'Rated %f out of 5', 'woo-gutenberg-products-block' ),
+ sprintf( '%f', rating )
+ ),
+ };
+
+ return (
+
+
+
+
+ { ratedProductsCount ? `(${ ratedProductsCount })` : null }
+
+ );
+};
+interface RatingProps {
+ className: string;
+ key: 0 | 1 | 2 | 3 | 4 | 5;
+ rating: 0 | 1 | 2 | 3 | 4 | 5;
+ ratedProductsCount: number;
+}
+
+export default Rating;
diff --git a/assets/js/base/components/product-rating/style.scss b/assets/js/base/components/product-rating/style.scss
new file mode 100644
index 00000000000..6827532c30d
--- /dev/null
+++ b/assets/js/base/components/product-rating/style.scss
@@ -0,0 +1,7 @@
+.wc-block-components-product-rating {
+ &__stars {
+ display: inline-block;
+ line-height: 1;
+ height: 1em;
+ }
+}
diff --git a/assets/js/base/context/hooks/collections/use-collection-data.ts b/assets/js/base/context/hooks/collections/use-collection-data.ts
index fdce755b002..98c16047f9e 100644
--- a/assets/js/base/context/hooks/collections/use-collection-data.ts
+++ b/assets/js/base/context/hooks/collections/use-collection-data.ts
@@ -45,6 +45,7 @@ interface UseCollectionDataProps {
};
queryPrices?: boolean;
queryStock?: boolean;
+ queryRating?: boolean;
queryState: Record< string, unknown >;
}
@@ -52,6 +53,7 @@ export const useCollectionData = ( {
queryAttribute,
queryPrices,
queryStock,
+ queryRating,
queryState,
}: UseCollectionDataProps ) => {
let context = useQueryStateContext();
@@ -66,10 +68,13 @@ export const useCollectionData = ( {
calculateStockStatusQueryState,
setCalculateStockStatusQueryState,
] = useQueryStateByKey( 'calculate_stock_status_counts', null, context );
+ const [ calculateRatingQueryState, setCalculateRatingQueryState ] =
+ useQueryStateByKey( 'calculate_rating_counts', null, context );
const currentQueryAttribute = useShallowEqual( queryAttribute || {} );
const currentQueryPrices = useShallowEqual( queryPrices );
const currentQueryStock = useShallowEqual( queryStock );
+ const currentQueryRating = useShallowEqual( queryRating );
useEffect( () => {
if (
@@ -124,6 +129,19 @@ export const useCollectionData = ( {
calculateStockStatusQueryState,
] );
+ useEffect( () => {
+ if (
+ calculateRatingQueryState !== currentQueryRating &&
+ currentQueryRating !== undefined
+ ) {
+ setCalculateRatingQueryState( currentQueryRating );
+ }
+ }, [
+ currentQueryRating,
+ setCalculateRatingQueryState,
+ calculateRatingQueryState,
+ ] );
+
// Defer the select query so all collection-data query vars can be gathered.
const [ shouldSelect, setShouldSelect ] = useState( false );
const [ debouncedShouldSelect ] = useDebounce( shouldSelect, 200 );
diff --git a/assets/js/blocks/active-filters/block.tsx b/assets/js/blocks/active-filters/block.tsx
index 0929e22181a..d529b284749 100644
--- a/assets/js/blocks/active-filters/block.tsx
+++ b/assets/js/blocks/active-filters/block.tsx
@@ -185,7 +185,7 @@ const ActiveFiltersBlock = ( {
] );
const [ productRatings, setProductRatings ] =
- useQueryStateByKey( 'ratings' );
+ useQueryStateByKey( 'rating' );
/**
* Parse the filter URL to set the active rating fitlers.
@@ -348,6 +348,7 @@ const ActiveFiltersBlock = ( {
setMaxPrice( undefined );
setProductAttributes( [] );
setProductStockStatus( [] );
+ setProductRatings( [] );
}
} }
>
diff --git a/assets/js/blocks/rating-filter/attributes.ts b/assets/js/blocks/rating-filter/attributes.ts
new file mode 100644
index 00000000000..6a3b3136423
--- /dev/null
+++ b/assets/js/blocks/rating-filter/attributes.ts
@@ -0,0 +1,11 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+export const blockAttributes = {
+ heading: {
+ type: 'string',
+ default: __( 'Filter by rating', 'woo-gutenberg-products-block' ),
+ },
+};
diff --git a/assets/js/blocks/rating-filter/block.json b/assets/js/blocks/rating-filter/block.json
new file mode 100644
index 00000000000..abeed83f211
--- /dev/null
+++ b/assets/js/blocks/rating-filter/block.json
@@ -0,0 +1,34 @@
+{
+ "name": "woocommerce/rating-filter",
+ "version": "1.0.0",
+ "title": "Filter by Rating",
+ "description": "Enable customers to filter the product grid by rating.",
+ "category": "woocommerce",
+ "keywords": [ "WooCommerce" ],
+ "supports": {
+ "html": false,
+ "multiple": false
+ },
+ "example": {
+ "attributes": {
+ "isPreview": true
+ }
+ },
+ "attributes": {
+ "className": {
+ "type": "string",
+ "default": ""
+ },
+ "headingLevel": {
+ "type": "number",
+ "default": 3
+ },
+ "isPreview": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "textdomain": "woo-gutenberg-products-block",
+ "apiVersion": 2,
+ "$schema": "https://schemas.wp.org/trunk/block.json"
+}
diff --git a/assets/js/blocks/rating-filter/block.tsx b/assets/js/blocks/rating-filter/block.tsx
new file mode 100644
index 00000000000..7cd395f5145
--- /dev/null
+++ b/assets/js/blocks/rating-filter/block.tsx
@@ -0,0 +1,255 @@
+/**
+ * External dependencies
+ */
+import Rating from '@woocommerce/base-components/product-rating';
+import { usePrevious, useShallowEqual } from '@woocommerce/base-hooks';
+import {
+ useQueryStateByKey,
+ useQueryStateByContext,
+ useCollectionData,
+} from '@woocommerce/base-context/hooks';
+import { getSettingWithCoercion } from '@woocommerce/settings';
+import { isBoolean, isObject, objectHasProp } from '@woocommerce/types';
+import FilterTitlePlaceholder from '@woocommerce/base-components/filter-placeholder';
+import isShallowEqual from '@wordpress/is-shallow-equal';
+import { useState, useCallback, useMemo, useEffect } from '@wordpress/element';
+import CheckboxList from '@woocommerce/base-components/checkbox-list';
+import { addQueryArgs, removeQueryArgs } from '@wordpress/url';
+import { changeUrl } from '@woocommerce/utils';
+import classnames from 'classnames';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import { Attributes } from './types';
+import { getActiveFilters } from './utils';
+
+export const QUERY_PARAM_KEY = 'rating_filter';
+
+/**
+ * Component displaying an stock status filter.
+ *
+ * @param {Object} props Incoming props for the component.
+ * @param {Object} props.attributes Incoming block attributes.
+ * @param {boolean} props.isEditor
+ */
+const RatingFilterBlock = ( {
+ attributes: blockAttributes,
+ isEditor = false,
+}: {
+ attributes: Attributes;
+ isEditor?: boolean;
+} ) => {
+ const filteringForPhpTemplate = getSettingWithCoercion(
+ 'is_rendering_php_template',
+ false,
+ isBoolean
+ );
+ const [ hasSetFilterDefaultsFromUrl, setHasSetFilterDefaultsFromUrl ] =
+ useState( false );
+
+ const [ queryState ] = useQueryStateByContext();
+
+ const { results: filteredCounts, isLoading: filteredCountsLoading } =
+ useCollectionData( {
+ queryRating: true,
+ queryState,
+ } );
+
+ const TagName =
+ `h${ blockAttributes.headingLevel }` as keyof JSX.IntrinsicElements;
+ const isLoading = ! blockAttributes.isPreview && filteredCountsLoading;
+ const isDisabled = ! blockAttributes.isPreview && ! filteredCountsLoading;
+
+ const initialFilters = useMemo(
+ () => getActiveFilters( 'rating_filter' ),
+ []
+ );
+
+ const [ clicked, setClicked ] = useState( initialFilters );
+
+ const [ productRatings, setProductRatings ] =
+ useQueryStateByKey( 'rating' );
+
+ const [ productRatingsQuery, setProductRatingsQuery ] = useQueryStateByKey(
+ 'rating',
+ initialFilters
+ );
+
+ const productRatingsArray: string[] = Array.from( productRatings );
+
+ /**
+ * Used to redirect the page when filters are changed so templates using the Classic Template block can filter.
+ *
+ * @param {Array} clickedRatings Array of clicked ratings.
+ */
+ const updateFilterUrl = ( clickedRatings: string[] ) => {
+ if ( ! window ) {
+ return;
+ }
+
+ if ( clickedRatings.length === 0 ) {
+ const url = removeQueryArgs(
+ window.location.href,
+ QUERY_PARAM_KEY
+ );
+
+ if ( url !== window.location.href ) {
+ changeUrl( url );
+ }
+
+ return;
+ }
+
+ const newUrl = addQueryArgs( window.location.href, {
+ [ QUERY_PARAM_KEY ]: clickedRatings.join( ',' ),
+ } );
+
+ if ( newUrl === window.location.href ) {
+ return;
+ }
+
+ changeUrl( newUrl );
+ };
+
+ const onSubmit = useCallback(
+ ( isClicked ) => {
+ if ( isEditor ) {
+ return;
+ }
+ if ( isClicked && ! filteringForPhpTemplate ) {
+ setProductRatingsQuery( clicked );
+ }
+
+ updateFilterUrl( clicked );
+ },
+ [ isEditor, setProductRatingsQuery, clicked, filteringForPhpTemplate ]
+ );
+
+ // Track clicked STATE changes - if state changes, update the query.
+ useEffect( () => {
+ onSubmit( clicked );
+ }, [ clicked, onSubmit ] );
+
+ const clickedQuery = useMemo( () => {
+ return productRatingsQuery;
+ }, [ productRatingsQuery ] );
+
+ const currentClickedQuery = useShallowEqual( clickedQuery );
+ const previousClickedQuery = usePrevious( currentClickedQuery );
+ // Track Rating query changes so the block reflects current filters.
+ useEffect( () => {
+ if (
+ ! isShallowEqual( previousClickedQuery, currentClickedQuery ) && // Clicked query changed.
+ ! isShallowEqual( clicked, currentClickedQuery ) // Clicked query doesn't match the UI.
+ ) {
+ setClicked( currentClickedQuery );
+ }
+ }, [ clicked, currentClickedQuery, previousClickedQuery ] );
+
+ /**
+ * Try get the rating filter from the URL.
+ */
+ useEffect( () => {
+ if ( ! hasSetFilterDefaultsFromUrl ) {
+ setProductRatings( initialFilters );
+ setHasSetFilterDefaultsFromUrl( true );
+ }
+ }, [
+ setProductRatings,
+ hasSetFilterDefaultsFromUrl,
+ setHasSetFilterDefaultsFromUrl,
+ initialFilters,
+ ] );
+
+ /**
+ * When a checkbox in the list changes, update state.
+ */
+ const onClick = useCallback(
+ ( clickedValue: string ) => {
+ if ( ! productRatingsArray.length ) {
+ setProductRatings( [ clickedValue ] );
+ } else {
+ const previouslyClicked =
+ productRatingsArray.includes( clickedValue );
+ const newClicked = productRatingsArray.filter(
+ ( value ) => value !== clickedValue
+ );
+ if ( ! previouslyClicked ) {
+ newClicked.push( clickedValue );
+ newClicked.sort();
+ }
+ setProductRatings( newClicked );
+ }
+ },
+ [ productRatingsArray, setProductRatings ]
+ );
+
+ const heading = (
+
+ { blockAttributes.heading }
+
+ );
+
+ const filterHeading = isLoading ? (
+ { heading }
+ ) : (
+ heading
+ );
+
+ const orderedRatings =
+ ! filteredCountsLoading &&
+ objectHasProp( filteredCounts, 'rating_counts' ) &&
+ Array.isArray( filteredCounts.rating_counts )
+ ? [ ...filteredCounts.rating_counts ].reverse()
+ : [];
+
+ const displayedOptions = orderedRatings
+ .filter(
+ ( item ) => isObject( item ) && Object.keys( item ).length > 0
+ )
+ .map( ( item ) => {
+ return {
+ label: (
+
+ ),
+ value: item?.rating?.toString(),
+ };
+ } );
+
+ return (
+ <>
+ { ! isEditor && blockAttributes.heading && filterHeading }
+
+ {
+ onClick( checked.toString() );
+ } }
+ isLoading={ isLoading }
+ isDisabled={ isDisabled }
+ />
+
+ >
+ );
+};
+
+export default RatingFilterBlock;
diff --git a/assets/js/blocks/rating-filter/edit.tsx b/assets/js/blocks/rating-filter/edit.tsx
new file mode 100644
index 00000000000..a63b990fc1e
--- /dev/null
+++ b/assets/js/blocks/rating-filter/edit.tsx
@@ -0,0 +1,47 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { useBlockProps } from '@wordpress/block-editor';
+import { Disabled, withSpokenMessages } from '@wordpress/components';
+import BlockTitle from '@woocommerce/editor-components/block-title';
+import type { BlockEditProps } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import Block from './block';
+import { Attributes } from './types';
+
+const Edit = ( {
+ attributes,
+ setAttributes,
+}: BlockEditProps< Attributes > ) => {
+ const { className, heading, headingLevel } = attributes;
+
+ const blockProps = useBlockProps( {
+ className: classnames( 'wc-block-rating-filter', className ),
+ } );
+
+ return (
+ <>
+ {
+
+
+ setAttributes( { heading: value } )
+ }
+ />
+
+
+
+
+ }
+ >
+ );
+};
+
+export default withSpokenMessages( Edit );
diff --git a/assets/js/blocks/rating-filter/frontend.ts b/assets/js/blocks/rating-filter/frontend.ts
new file mode 100644
index 00000000000..420a132834d
--- /dev/null
+++ b/assets/js/blocks/rating-filter/frontend.ts
@@ -0,0 +1,29 @@
+/**
+ * External dependencies
+ */
+import { renderFrontend } from '@woocommerce/base-utils';
+
+/**
+ * Internal dependencies
+ */
+import Block from './block';
+import metadata from './block.json';
+import { blockAttributes } from './attributes';
+
+const getProps = ( el: HTMLElement ) => {
+ return {
+ attributes: {
+ heading: el.dataset.heading || blockAttributes.heading.default,
+ headingLevel: el.dataset.headingLevel
+ ? parseInt( el.dataset.headingLevel, 10 )
+ : metadata.attributes.headingLevel.default,
+ },
+ isEditor: false,
+ };
+};
+
+renderFrontend( {
+ selector: '.wp-block-woocommerce-rating-filter',
+ Block,
+ getProps,
+} );
diff --git a/assets/js/blocks/rating-filter/index.tsx b/assets/js/blocks/rating-filter/index.tsx
new file mode 100644
index 00000000000..386a2f381d0
--- /dev/null
+++ b/assets/js/blocks/rating-filter/index.tsx
@@ -0,0 +1,55 @@
+/**
+ * External dependencies
+ */
+import { isExperimentalBuild } from '@woocommerce/block-settings';
+import { registerBlockType } from '@wordpress/blocks';
+import { Icon, starEmpty } from '@wordpress/icons';
+import classNames from 'classnames';
+import { useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import metadata from './block.json';
+import { blockAttributes } from './attributes';
+import type { Attributes } from './types';
+
+if ( isExperimentalBuild() ) {
+ registerBlockType( metadata, {
+ icon: {
+ src: (
+
+ ),
+ },
+ attributes: {
+ ...metadata.attributes,
+ ...blockAttributes,
+ },
+ edit,
+ // Save the props to post content.
+ save( { attributes }: { attributes: Attributes } ) {
+ const { className, heading, headingLevel } = attributes;
+ const data: Record< string, unknown > = {
+ 'data-heading': heading,
+ 'data-heading-level': headingLevel,
+ };
+ return (
+
+
+
+ );
+ },
+ } );
+}
diff --git a/assets/js/blocks/rating-filter/style.scss b/assets/js/blocks/rating-filter/style.scss
new file mode 100644
index 00000000000..5567e92a536
--- /dev/null
+++ b/assets/js/blocks/rating-filter/style.scss
@@ -0,0 +1,26 @@
+.wc-block-rating-filter {
+ &.is-loading {
+ @include placeholder();
+ margin-top: $gap;
+ box-shadow: none;
+ border-radius: 0;
+ }
+}
+
+.wp-block-woocommerce-rating-filter {
+ margin-bottom: $gap-large;
+
+ .wc-block-components-product-rating {
+ &.is-active {
+ font-weight: 700;
+ .wc-block-components-product-rating__stars span::before {
+ color: var(--wp--preset--color--primary, #7f54b3);
+ }
+ }
+ }
+
+ .wc-block-rating-filter .wc-block-rating-filter-list li input,
+ .wc-block-rating-filter .wc-block-rating-filter-list li label {
+ cursor: pointer;
+ }
+}
diff --git a/assets/js/blocks/rating-filter/types.ts b/assets/js/blocks/rating-filter/types.ts
new file mode 100644
index 00000000000..4d0b5d3b33e
--- /dev/null
+++ b/assets/js/blocks/rating-filter/types.ts
@@ -0,0 +1,11 @@
+export interface Attributes {
+ className?: string;
+ heading: string;
+ headingLevel: number;
+ isPreview?: boolean;
+}
+
+export interface DisplayOption {
+ label: JSX.Element;
+ value: string;
+}
diff --git a/assets/js/blocks/rating-filter/utils.ts b/assets/js/blocks/rating-filter/utils.ts
new file mode 100644
index 00000000000..71b053db920
--- /dev/null
+++ b/assets/js/blocks/rating-filter/utils.ts
@@ -0,0 +1,19 @@
+/**
+ * External dependencies
+ */
+import { isString } from '@woocommerce/types';
+import { getUrlParameter } from '@woocommerce/utils';
+
+export const getActiveFilters = ( queryParamKey = 'filter_rating' ) => {
+ const params = getUrlParameter( queryParamKey );
+
+ if ( ! params ) {
+ return [];
+ }
+
+ const parsedParams = isString( params )
+ ? params.split( ',' )
+ : ( params as string[] );
+
+ return parsedParams;
+};
diff --git a/bin/webpack-entries.js b/bin/webpack-entries.js
index 4e717d948e0..9687891a3bd 100644
--- a/bin/webpack-entries.js
+++ b/bin/webpack-entries.js
@@ -44,6 +44,7 @@ const blocks = {
'attribute-filter': {},
'stock-filter': {},
'active-filters': {},
+ 'rating-filter': {},
cart: {},
checkout: {},
'mini-cart': {},
diff --git a/src/BlockTypes/RatingFilter.php b/src/BlockTypes/RatingFilter.php
new file mode 100644
index 00000000000..b467ef8f95f
--- /dev/null
+++ b/src/BlockTypes/RatingFilter.php
@@ -0,0 +1,15 @@
+