From 75a8ecc1996a05d78cdbc661a012f8761cf506d6 Mon Sep 17 00:00:00 2001 From: Daniel Dudzic Date: Wed, 28 Sep 2022 23:12:14 +0200 Subject: [PATCH] Add a Products by Rating filter block (#7048) * Add interactivity to the Product by Rating filter block * Fix block with the latest repo changes * Product by Rating: Code tidying up * Add an experimental build gate and update block title and description * Remove redundant title and description * Add support for the CheckboxList component in the Products by Rating block * Products by Rating: Minor code clean-up * Active Filters: Fix the Clear All button for Ratings. Closes ##7172 * Products by Rating: Add misc TS fixes --- .../components/filter-placeholder/style.scss | 3 +- .../components/product-list/product-list.tsx | 7 + .../base/components/product-rating/index.tsx | 64 +++++ .../base/components/product-rating/style.scss | 7 + .../hooks/collections/use-collection-data.ts | 18 ++ assets/js/blocks/active-filters/block.tsx | 3 +- assets/js/blocks/rating-filter/attributes.ts | 11 + assets/js/blocks/rating-filter/block.json | 34 +++ assets/js/blocks/rating-filter/block.tsx | 255 ++++++++++++++++++ assets/js/blocks/rating-filter/edit.tsx | 47 ++++ assets/js/blocks/rating-filter/frontend.ts | 29 ++ assets/js/blocks/rating-filter/index.tsx | 55 ++++ assets/js/blocks/rating-filter/style.scss | 26 ++ assets/js/blocks/rating-filter/types.ts | 11 + assets/js/blocks/rating-filter/utils.ts | 19 ++ bin/webpack-entries.js | 1 + src/BlockTypes/RatingFilter.php | 15 ++ src/BlockTypesController.php | 1 + 18 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 assets/js/base/components/product-rating/index.tsx create mode 100644 assets/js/base/components/product-rating/style.scss create mode 100644 assets/js/blocks/rating-filter/attributes.ts create mode 100644 assets/js/blocks/rating-filter/block.json create mode 100644 assets/js/blocks/rating-filter/block.tsx create mode 100644 assets/js/blocks/rating-filter/edit.tsx create mode 100644 assets/js/blocks/rating-filter/frontend.ts create mode 100644 assets/js/blocks/rating-filter/index.tsx create mode 100644 assets/js/blocks/rating-filter/style.scss create mode 100644 assets/js/blocks/rating-filter/types.ts create mode 100644 assets/js/blocks/rating-filter/utils.ts create mode 100644 src/BlockTypes/RatingFilter.php 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 @@ +