diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/block.json b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/block.json index 9759c49964d..54d6a761453 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/block.json +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/block.json @@ -1,13 +1,13 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, - "name": "woocommerce/product-collection", - "version": "1.0.0", - "title": "Product Collection (Beta)", - "description": "Display a collection of products from your store.", - "category": "woocommerce", - "keywords": [ "WooCommerce", "Products (Beta)" ], - "textdomain": "woocommerce", + "apiVersion": 2, + "name": "woocommerce/product-collection", + "version": "1.0.0", + "title": "Product Collection (Beta)", + "description": "Display a collection of products from your store.", + "category": "woocommerce", + "keywords": [ "WooCommerce", "Products (Beta)" ], + "textdomain": "woocommerce", "attributes": { "queryId": { "type": "number" @@ -24,6 +24,13 @@ "convertedFromProducts": { "type": "boolean", "default": false + }, + "collection": { + "type": "string" + }, + "hideControls": { + "default": [], + "type": "array" } }, "providesContext": { diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/README.md b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/README.md new file mode 100644 index 00000000000..a9cecadf6bb --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/README.md @@ -0,0 +1,47 @@ +# Product Collection - Collections + +_Note: Collections documented here are internal implementation. It's not a way to register custom Collections, however we're going to expose public API for that._ + +Collections are a variations of Product Collection block with the predefined attributes which includes: + +- UI aspect - you can define layout, number of columns etc. +- Query - specify the filters and sorting of the products +- Inner blocks structure - define the Product Template structure + +## Interface + +Collections are in fact Variations and they are registered via Variation API. Hence they should follow the BlockVariation type, providing at least: + +```typescript +type Collection ={ + name: string; + title: string; + icon: Icon; + description: string; + attributes: ProductCollectionAttributes; + innerBlocks: InnerBlockTemplate[]; + isActive?: + (blockAttrs: BlockAttributes, variationAttributes: BlockAttributes) => boolean; +} +``` + +Please be aware you can specify `isActive` function, but if not, the default one will compare the variation's `name` with `attributes.collection` value. + +As an example please follow `./new-arrivals.tsx`. + +## Collection can hide Inspector Controls filters from users + +Let's take New Arrivals as an example. What defines New Arrivals is the product order: from newest to oldest. Users can apply additional filters on top of it, for example, "On Sale" but shouldn't be able to change ordering because that would no longer be New Arrivals Collection. + +To achieve this add additional property to collection definition: + +```typescript +type Collection = { + ...; + hideControls: FilterName[]; +} +``` + +## Registering Collection + +To register collection import it in `./index.ts` file and add to the `collections` array. diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/best-sellers.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/best-sellers.tsx new file mode 100644 index 00000000000..c0247b6876f --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/best-sellers.tsx @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import { Icon, chartBar } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { + DEFAULT_ATTRIBUTES, + INNER_BLOCKS_PRODUCT_TEMPLATE, +} from '../constants'; +import { CoreCollectionNames, CoreFilterNames } from '../types'; + +const collection = { + name: CoreCollectionNames.BEST_SELLERS, + title: __( 'Best Sellers', 'woocommerce' ), + icon: ( ) as BlockIcon, + description: __( 'Recommend your best-selling products.', 'woocommerce' ), + keywords: [ 'best selling', 'product collection' ], + scope: [], +}; + +const attributes = { + ...DEFAULT_ATTRIBUTES, + displayLayout: { + type: 'flex', + columns: 5, + shrinkColumns: true, + }, + query: { + ...DEFAULT_ATTRIBUTES.query, + inherit: false, + orderBy: 'popularity', + order: 'desc', + perPage: 5, + pages: 1, + }, + collection: collection.name, + hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.ORDER ], +}; + +const heading: InnerBlockTemplate = [ + 'core/heading', + { + textAlign: 'center', + level: 2, + content: __( 'Best selling products', 'woocommerce' ), + style: { spacing: { margin: { bottom: '1rem' } } }, + }, +]; + +const innerBlocks: InnerBlockTemplate[] = [ + heading, + INNER_BLOCKS_PRODUCT_TEMPLATE, +]; + +export default { + ...collection, + attributes, + innerBlocks, +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/featured.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/featured.tsx new file mode 100644 index 00000000000..62861ede42b --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/featured.tsx @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import { Icon, starFilled } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { + DEFAULT_ATTRIBUTES, + INNER_BLOCKS_PRODUCT_TEMPLATE, +} from '../constants'; +import { CoreCollectionNames, CoreFilterNames } from '../types'; + +const collection = { + name: CoreCollectionNames.FEATURED, + title: __( 'Featured', 'woocommerce' ), + icon: ( ) as BlockIcon, + description: __( 'Showcase your featured products.', 'woocommerce' ), + keywords: [ 'product collection' ], + scope: [], +}; + +const attributes = { + ...DEFAULT_ATTRIBUTES, + displayLayout: { + type: 'flex', + columns: 5, + shrinkColumns: true, + }, + query: { + ...DEFAULT_ATTRIBUTES.query, + inherit: false, + featured: true, + perPage: 5, + pages: 1, + }, + collection: collection.name, + hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.FEATURED ], +}; + +const heading: InnerBlockTemplate = [ + 'core/heading', + { + textAlign: 'center', + level: 2, + content: __( 'Featured products', 'woocommerce' ), + style: { spacing: { margin: { bottom: '1rem' } } }, + }, +]; + +const innerBlocks: InnerBlockTemplate[] = [ + heading, + INNER_BLOCKS_PRODUCT_TEMPLATE, +]; + +export default { + ...collection, + attributes, + innerBlocks, +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx new file mode 100644 index 00000000000..39bce78d22f --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { + type BlockVariation, + registerBlockVariation, + BlockAttributes, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { CollectionName } from '../types'; +import blockJson from '../block.json'; +import productCollection from './product-collection'; +import newArrivals from './new-arrivals'; +import topRated from './top-rated'; +import bestSellers from './best-sellers'; +import onSale from './on-sale'; +import featured from './featured'; + +const collections: BlockVariation[] = [ + productCollection, + featured, + topRated, + onSale, + bestSellers, + newArrivals, +]; + +export const registerCollections = () => { + collections.forEach( ( collection ) => { + const isActive = ( + blockAttrs: BlockAttributes, + variationAttributes: BlockAttributes + ) => { + return blockAttrs.collection === variationAttributes.collection; + }; + + registerBlockVariation( blockJson.name, { + isActive, + ...collection, + } ); + } ); +}; + +export const getCollectionByName = ( collectionName?: CollectionName ) => { + if ( ! collectionName ) { + return null; + } + + return collections.find( ( { name } ) => name === collectionName ); +}; + +export default registerCollections; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/new-arrivals.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/new-arrivals.tsx new file mode 100644 index 00000000000..d8326800303 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/new-arrivals.tsx @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import { Icon, calendar } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { + DEFAULT_ATTRIBUTES, + INNER_BLOCKS_PRODUCT_TEMPLATE, +} from '../constants'; +import { CoreCollectionNames, CoreFilterNames } from '../types'; + +const collection = { + name: CoreCollectionNames.NEW_ARRIVALS, + title: __( 'New Arrivals', 'woocommerce' ), + icon: ( ) as BlockIcon, + description: __( 'Recommend your newest products.', 'woocommerce' ), + keywords: [ 'newest products', 'product collection' ], + scope: [], +}; + +const attributes = { + ...DEFAULT_ATTRIBUTES, + displayLayout: { + type: 'flex', + columns: 5, + shrinkColumns: true, + }, + query: { + ...DEFAULT_ATTRIBUTES.query, + inherit: false, + orderBy: 'date', + order: 'desc', + perPage: 5, + pages: 1, + }, + collection: collection.name, + hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.ORDER ], +}; + +const heading: InnerBlockTemplate = [ + 'core/heading', + { + textAlign: 'center', + level: 2, + content: __( 'New arrivals', 'woocommerce' ), + style: { spacing: { margin: { bottom: '1rem' } } }, + }, +]; + +const innerBlocks: InnerBlockTemplate[] = [ + heading, + INNER_BLOCKS_PRODUCT_TEMPLATE, +]; + +export default { + ...collection, + attributes, + innerBlocks, +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/on-sale.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/on-sale.tsx new file mode 100644 index 00000000000..8d4dd3969f5 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/on-sale.tsx @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import { Icon, percent } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { + DEFAULT_ATTRIBUTES, + INNER_BLOCKS_PRODUCT_TEMPLATE, +} from '../constants'; +import { CoreCollectionNames, CoreFilterNames } from '../types'; + +const collection = { + name: CoreCollectionNames.ON_SALE, + title: __( 'On Sale', 'woocommerce' ), + icon: ( ) as BlockIcon, + description: __( + 'Highlight products that are currently on sale.', + 'woocommerce' + ), + keywords: [ 'product collection' ], + scope: [], +}; + +const attributes = { + ...DEFAULT_ATTRIBUTES, + displayLayout: { + type: 'flex', + columns: 5, + shrinkColumns: true, + }, + query: { + ...DEFAULT_ATTRIBUTES.query, + inherit: false, + woocommerceOnSale: true, + perPage: 5, + pages: 1, + }, + collection: collection.name, + hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.ON_SALE ], +}; + +const heading: InnerBlockTemplate = [ + 'core/heading', + { + textAlign: 'center', + level: 2, + content: __( 'On sale products', 'woocommerce' ), + style: { spacing: { margin: { bottom: '1rem' } } }, + }, +]; + +const innerBlocks: InnerBlockTemplate[] = [ + heading, + INNER_BLOCKS_PRODUCT_TEMPLATE, +]; + +export default { + ...collection, + attributes, + innerBlocks, +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/product-collection.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/product-collection.tsx new file mode 100644 index 00000000000..e35207155bb --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/product-collection.tsx @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import { Icon, loop } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants'; +import { CoreCollectionNames } from '../types'; + +const collection = { + name: CoreCollectionNames.PRODUCT_CATALOG, + title: __( 'Product Catalog', 'woocommerce' ), + icon: ( ) as BlockIcon, + description: + 'Display all products in your catalog. Results can (change to) match the current template, page, or search term.', + keywords: [ 'all products' ], + scope: [], +}; + +const attributes = { + ...DEFAULT_ATTRIBUTES, + query: { + ...DEFAULT_ATTRIBUTES.query, + inherit: true, + }, + collection: collection.name, + hideControls: [], +}; + +const innerBlocks: InnerBlockTemplate[] = INNER_BLOCKS_TEMPLATE; + +export default { + ...collection, + attributes, + innerBlocks, +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/top-rated.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/top-rated.tsx new file mode 100644 index 00000000000..126184b9ab2 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/top-rated.tsx @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import { Icon, starEmpty } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { + DEFAULT_ATTRIBUTES, + INNER_BLOCKS_PRODUCT_TEMPLATE, +} from '../constants'; +import { CoreCollectionNames, CoreFilterNames } from '../types'; + +const collection = { + name: CoreCollectionNames.TOP_RATED, + title: __( 'Top Rated', 'woocommerce' ), + icon: ( ) as BlockIcon, + description: __( + 'Recommend products with the highest review ratings.', + 'woocommerce' + ), + keywords: [ 'product collection' ], + scope: [], +}; + +const attributes = { + ...DEFAULT_ATTRIBUTES, + displayLayout: { + type: 'flex', + columns: 5, + shrinkColumns: true, + }, + query: { + ...DEFAULT_ATTRIBUTES.query, + inherit: false, + orderBy: 'rating', + order: 'desc', + perPage: 5, + pages: 1, + }, + collection: collection.name, + hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.ORDER ], +}; + +const heading: InnerBlockTemplate = [ + 'core/heading', + { + textAlign: 'center', + level: 2, + content: __( 'Top rated products', 'woocommerce' ), + style: { spacing: { margin: { bottom: '1rem' } } }, + }, +]; + +const innerBlocks: InnerBlockTemplate[] = [ + heading, + INNER_BLOCKS_PRODUCT_TEMPLATE, +]; + +export default { + ...collection, + attributes, + innerBlocks, +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/collection-chooser.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/collection-chooser.tsx new file mode 100644 index 00000000000..ac7ea086d7b --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/collection-chooser.tsx @@ -0,0 +1,118 @@ +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; +import { Button } from '@wordpress/components'; +import { + BlockInstance, + createBlock, + // @ts-expect-error Type definitions for this function are missing in Guteberg + createBlocksFromInnerBlocksTemplate, + // @ts-expect-error Type definitions for this function are missing in Guteberg + store as blocksStore, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { type CollectionName, CoreCollectionNames } from '../types'; +import blockJson from '../block.json'; +import { getCollectionByName } from '../collections'; +import { getDefaultProductCollection } from '../constants'; + +type CollectionButtonProps = { + active?: boolean; + title: string; + icon: string; + description: string; + onClick: () => void; +}; + +export const applyCollection = ( + collectionName: CollectionName, + clientId: string, + replaceBlock: ( clientId: string, block: BlockInstance ) => void +) => { + const collection = getCollectionByName( collectionName ); + + if ( ! collection ) { + return; + } + + const newBlock = + collection.name === CoreCollectionNames.PRODUCT_CATALOG + ? getDefaultProductCollection() + : createBlock( + blockJson.name, + collection.attributes, + createBlocksFromInnerBlocksTemplate( + collection.innerBlocks + ) + ); + + replaceBlock( clientId, newBlock ); +}; + +const CollectionButton = ( { + active = false, + title, + icon, + description, + onClick, +}: CollectionButtonProps ) => { + const variant = active ? 'primary' : 'secondary'; + + return ( + + ); +}; + +const CollectionChooser = ( props: { + chosenCollection?: CollectionName | undefined; + onCollectionClick: ( name: string ) => void; +} ) => { + const { chosenCollection, onCollectionClick } = props; + + // Get Collections + const blockCollections = [ + ...useSelect( ( select ) => { + // @ts-expect-error Type definitions are missing + // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wordpress__blocks/store/selectors.d.ts + const { getBlockVariations } = select( blocksStore ); + return getBlockVariations( blockJson.name ); + }, [] ), + ]; + + return ( +
+ { blockCollections.map( ( { name, title, icon, description } ) => ( + onCollectionClick( name ) } + /> + ) ) } +
+ ); +}; + +export default CollectionChooser; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/collection-selection-modal.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/collection-selection-modal.tsx new file mode 100644 index 00000000000..2d1414ce234 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/collection-selection-modal.tsx @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { useDispatch } from '@wordpress/data'; +import { Modal, Button } from '@wordpress/components'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import CollectionChooser, { applyCollection } from './collection-chooser'; +import type { ProductCollectionAttributes } from '../types'; + +const PatternSelectionModal = ( props: { + clientId: string; + attributes: ProductCollectionAttributes; + closePatternSelectionModal: () => void; +} ) => { + const { clientId, attributes } = props; + // @ts-expect-error Type definitions for this function are missing + // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wordpress__blocks/store/actions.d.ts + const { replaceBlock } = useDispatch( blockEditorStore ); + + const [ chosenCollection, selectCollectionName ] = useState( + attributes.collection + ); + + const onContinueClick = () => { + if ( chosenCollection ) { + applyCollection( chosenCollection, clientId, replaceBlock ); + } + }; + + return ( + +
+

+ { __( + "Pick what products are shown. Don't worry, you can switch and tweak this collection any time.", + 'woocommerce' + ) } +

+ +
+ + +
+
+
+ ); +}; + +export default PatternSelectionModal; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/editor.scss b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/editor.scss index ac0cf54d719..213c64d8b48 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/editor.scss +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/editor.scss @@ -1,3 +1,9 @@ +$max-columns: 3; +$min-button-width: 250px; +$gap-count: calc(#{ $max-columns } - 1); +$total-gap-width: calc(#{ $gap-count } * #{ $gap-small }); +$max-button-width: calc((100% - #{ $total-gap-width }) / #{ $max-columns }); + .wc-block-editor-product-collection-inspector-toolspanel__filters { .wc-block-editor-product-collection-inspector__taxonomy-control:not(:last-child) { margin-bottom: $grid-unit-30; @@ -10,18 +16,76 @@ } } -.wc-blocks-product-collection__selection-modal { - .block-editor-block-patterns-list { - column-count: 3; - column-gap: $grid-unit-30; +// Need to override high-specificity styles +.wc-blocks-product-collection__placeholder.is-medium { + .components-placeholder__fieldset { + display: block; + } - @include breakpoint("<1280px") { - column-count: 2; + .components-button.wc-blocks-product-collection__collection-button { + margin: 0; + } +} + +.wc-blocks-product-collection__placeholder, +.wc-blocks-product-collection__modal { + .wc-blocks-product-collection__selection-subtitle { + margin-bottom: $gap-large; + } + + .wc-blocks-product-collection__collections-section { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(max(#{ $min-button-width }, #{ $max-button-width }), 1fr)); + grid-auto-rows: 1fr; + grid-gap: $gap-small; + margin: $gap-large auto; + max-width: 1000px; + } + + .wc-blocks-product-collection__collection-button { + color: var(--wp-admin-theme-color); + display: flex; + align-items: flex-start; + height: auto; + border-radius: $universal-border-radius; + box-sizing: border-box; + padding: $gap-smallest $gap-small; + margin: 0; + + &.is-primary { + box-shadow: 0 0 0 2px var(--wp-admin-theme-color, #3858e9); + color: var(--wp-admin-theme-color-darker-20); + background-color: var(--wc-content-bg, #fff); + + &:hover { + background-color: var(--wc-content-bg, #fff); + color: var(--wp-admin-theme-color-darker-20); + } } - @include breakpoint("<782px") { - column-count: 1; + .wc-blocks-product-collection__collection-button-icon { + margin: 1em 0; } + + .wc-blocks-product-collection__collection-button-text { + padding: 0 $gap-small; + text-align: left; + white-space: break-spaces; + } + + .wc-blocks-product-collection__collection-button-title { + @include font-size(large); + line-height: 1; + } + + .wc-blocks-product-collection__collection-button-description { + white-space: wrap; + } + } + + .wc-blocks-product-collection__footer { + text-align: end; + margin: $gap-small 0; } } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/index.tsx index 9dae873a53c..68e27644aaf 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/index.tsx @@ -1,74 +1,52 @@ /** * External dependencies */ -import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; +import { store as blockEditorStore } from '@wordpress/block-editor'; import { BlockEditProps } from '@wordpress/blocks'; -import { useInstanceId } from '@wordpress/compose'; -import { useEffect } from '@wordpress/element'; +import { useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies */ -import type { - ProductCollectionAttributes, - ProductCollectionQuery, -} from '../types'; -import InspectorControls from './inspector-controls'; -import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants'; +import type { ProductCollectionAttributes } from '../types'; +import ProductCollectionPlaceholder from './product-collection-placeholder'; +import ProductCollectionContent from './product-collection-content'; +import CollectionSelectionModal from './collection-selection-modal'; import './editor.scss'; -import { getDefaultValueOfInheritQueryFromTemplate } from '../utils'; -import ToolbarControls from './toolbar-controls'; const Edit = ( props: BlockEditProps< ProductCollectionAttributes > ) => { - const { attributes, setAttributes } = props; - const { queryId } = attributes; + const { clientId, attributes } = props; - const blockProps = useBlockProps(); - const innerBlocksProps = useInnerBlocksProps( blockProps, { - template: INNER_BLOCKS_TEMPLATE, - } ); + const [ isSelectionModalOpen, setIsSelectionModalOpen ] = useState( false ); + const hasInnerBlocks = useSelect( + ( select ) => + !! select( blockEditorStore ).getBlocks( clientId ).length, + [ clientId ] + ); - const instanceId = useInstanceId( Edit ); - - // We need this for multi-query block pagination. - // Query parameters for each block are scoped to their ID. - useEffect( () => { - if ( ! Number.isFinite( queryId ) ) { - setAttributes( { queryId: Number( instanceId ) } ); - } - }, [ queryId, instanceId, setAttributes ] ); - - /** - * Because of issue https://github.com/WordPress/gutenberg/issues/7342, - * We are using this workaround to set default attributes. - */ - useEffect( () => { - setAttributes( { - ...DEFAULT_ATTRIBUTES, - query: { - ...( DEFAULT_ATTRIBUTES.query as ProductCollectionQuery ), - inherit: getDefaultValueOfInheritQueryFromTemplate(), - }, - ...( attributes as Partial< ProductCollectionAttributes > ), - } ); - // We don't wanna add attributes as a dependency here. - // Because we want this to run only once. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ setAttributes ] ); - - /** - * If inherit is not a boolean, then we haven't set default attributes yet. - * We don't wanna render anything until default attributes are set. - * Default attributes are set in the useEffect above. - */ - if ( typeof attributes?.query?.inherit !== 'boolean' ) return null; + const Component = hasInnerBlocks + ? ProductCollectionContent + : ProductCollectionPlaceholder; return ( -
- - -
-
+ <> + + setIsSelectionModalOpen( true ) + } + /> + { isSelectionModalOpen && ( + + setIsSelectionModalOpen( false ) + } + /> + ) } + ); }; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/columns-control.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/columns-control.tsx index cd51bff71c9..908e495983a 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/columns-control.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/columns-control.tsx @@ -13,7 +13,7 @@ import { /** * Internal dependencies */ -import { DisplayLayoutToolbarProps } from '../../types'; +import { DisplayLayoutControlProps } from '../../types'; import { getDefaultDisplayLayout } from '../../constants'; const columnsLabel = __( 'Columns', 'woocommerce' ); @@ -23,7 +23,7 @@ const toggleHelp = __( 'woocommerce' ); -const ColumnsControl = ( props: DisplayLayoutToolbarProps ) => { +const ColumnsControl = ( props: DisplayLayoutControlProps ) => { const { type, columns, shrinkColumns } = props.displayLayout; const showColumnsControl = type === 'flex'; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx index f474544cec2..2a6b752bdcf 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx @@ -5,7 +5,7 @@ import type { BlockEditProps } from '@wordpress/blocks'; import { InspectorControls } from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n'; import { type ElementType, useMemo } from '@wordpress/element'; -import { EditorBlock } from '@woocommerce/types'; +import { EditorBlock, isEmpty } from '@woocommerce/types'; import { addFilter } from '@wordpress/hooks'; import { ProductCollectionFeedbackPrompt } from '@woocommerce/editor-components/feedback-prompt'; import { @@ -25,7 +25,11 @@ import { * Internal dependencies */ import metadata from '../../block.json'; -import { ProductCollectionAttributes } from '../../types'; +import { + ProductCollectionAttributes, + CoreFilterNames, + FilterName, +} from '../../types'; import { setQueryAttribute } from '../../utils'; import { DEFAULT_FILTERS, getDefaultSettings } from '../../constants'; import UpgradeNotice from './upgrade-notice'; @@ -43,12 +47,25 @@ import FeaturedProductsControl from './featured-products-control'; import CreatedControl from './created-control'; import PriceRangeControl from './price-range-control'; +const prepareShouldShowFilter = + ( hideControls: FilterName[] ) => ( filter: FilterName ) => { + return ! hideControls.includes( filter ); + }; + const ProductCollectionInspectorControls = ( props: BlockEditProps< ProductCollectionAttributes > ) => { - const query = props.attributes.query; + const { query, collection, hideControls } = props.attributes; const inherit = query?.inherit; - const displayQueryControls = inherit === false; + const shouldShowFilter = prepareShouldShowFilter( hideControls ); + + const showQueryControls = inherit === false; + const showInheritQueryControls = + isEmpty( collection ) || shouldShowFilter( CoreFilterNames.INHERIT ); + const showOrderControl = + showQueryControls && shouldShowFilter( CoreFilterNames.ORDER ); + const showFeaturedControl = shouldShowFilter( CoreFilterNames.FEATURED ); + const showOnSaleControl = shouldShowFilter( CoreFilterNames.ON_SALE ); const setQueryAttributeBind = useMemo( () => setQueryAttribute.bind( null, props ), @@ -76,15 +93,17 @@ const ProductCollectionInspectorControls = ( props.setAttributes( defaultSettings ); } } > + { showInheritQueryControls && ( + + ) } - - { displayQueryControls ? ( + { showOrderControl && ( - ) : null } + ) } - { displayQueryControls ? ( + { showQueryControls ? ( void )[] ) => { @@ -95,13 +114,17 @@ const ProductCollectionInspectorControls = ( } } className="wc-block-editor-product-collection-inspector-toolspanel__filters" > - + { showOnSaleControl && ( + + ) } - + { showFeaturedControl && ( + + ) } @@ -202,8 +225,4 @@ export const withUpgradeNoticeControls = ); }; -addFilter( - 'editor.BlockEdit', - 'woocommerce/product-collection', - withUpgradeNoticeControls -); +addFilter( 'editor.BlockEdit', metadata.name, withUpgradeNoticeControls ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/layout-options-control.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/layout-options-control.tsx index a48e8c9be8d..59b0d627ccd 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/layout-options-control.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/layout-options-control.tsx @@ -19,7 +19,7 @@ import { /** * Internal dependencies */ -import { DisplayLayoutToolbarProps, LayoutOptions } from '../../types'; +import { DisplayLayoutControlProps, LayoutOptions } from '../../types'; const getHelpText = ( layoutOptions: LayoutOptions ) => { switch ( layoutOptions ) { @@ -37,7 +37,7 @@ const getHelpText = ( layoutOptions: LayoutOptions ) => { const DEFAULT_VALUE = LayoutOptions.GRID; -const LayoutOptionsControl = ( props: DisplayLayoutToolbarProps ) => { +const LayoutOptionsControl = ( props: DisplayLayoutControlProps ) => { const { type, columns, shrinkColumns } = props.displayLayout; const setDisplayLayout = ( displayLayout: LayoutOptions ) => { props.setAttributes( { diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx new file mode 100644 index 00000000000..8eace5c6783 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; +import { useInstanceId } from '@wordpress/compose'; +import { useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { + ProductCollectionAttributes, + ProductCollectionQuery, + ProductCollectionEditComponentProps, +} from '../types'; +import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants'; +import { getDefaultValueOfInheritQueryFromTemplate } from '../utils'; +import InspectorControls from './inspector-controls'; +import ToolbarControls from './toolbar-controls'; + +const ProductCollectionContent = ( + props: ProductCollectionEditComponentProps +) => { + const { attributes, setAttributes } = props; + const { queryId } = attributes; + + const blockProps = useBlockProps(); + const innerBlocksProps = useInnerBlocksProps( blockProps, { + template: INNER_BLOCKS_TEMPLATE, + } ); + + const instanceId = useInstanceId( ProductCollectionContent ); + + // We need this for multi-query block pagination. + // Query parameters for each block are scoped to their ID. + useEffect( () => { + if ( ! Number.isFinite( queryId ) ) { + setAttributes( { queryId: Number( instanceId ) } ); + } + }, [ queryId, instanceId, setAttributes ] ); + + /** + * Because of issue https://github.com/WordPress/gutenberg/issues/7342, + * We are using this workaround to set default attributes. + */ + useEffect( () => { + setAttributes( { + ...DEFAULT_ATTRIBUTES, + query: { + ...( DEFAULT_ATTRIBUTES.query as ProductCollectionQuery ), + inherit: getDefaultValueOfInheritQueryFromTemplate(), + }, + ...( attributes as Partial< ProductCollectionAttributes > ), + } ); + // We don't wanna add attributes as a dependency here. + // Because we want this to run only once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ setAttributes ] ); + + /** + * If inherit is not a boolean, then we haven't set default attributes yet. + * We don't wanna render anything until default attributes are set. + * Default attributes are set in the useEffect above. + */ + if ( typeof attributes?.query?.inherit !== 'boolean' ) { + return null; + } + + return ( +
+ + +
+
+ ); +}; + +export default ProductCollectionContent; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-placeholder.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-placeholder.tsx new file mode 100644 index 00000000000..446fc2905ed --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-placeholder.tsx @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + store as blockEditorStore, + useBlockProps, +} from '@wordpress/block-editor'; +import { Placeholder } from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import CollectionChooser, { applyCollection } from './collection-chooser'; +import type { + CollectionName, + ProductCollectionEditComponentProps, +} from '../types'; +import Icon from '../icon'; + +const ProductCollectionPlaceholder = ( + props: ProductCollectionEditComponentProps +) => { + const blockProps = useBlockProps(); + const { clientId } = props; + // @ts-expect-error Type definitions for this function are missing + // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wordpress__blocks/store/actions.d.ts + const { replaceBlock } = useDispatch( blockEditorStore ); + + const onCollectionClick = ( collectionName: CollectionName ) => + applyCollection( collectionName, clientId, replaceBlock ); + + return ( +
+ + + +
+ ); +}; + +export default ProductCollectionPlaceholder; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/collection-chooser-toolbar.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/collection-chooser-toolbar.tsx new file mode 100644 index 00000000000..0652daf7fe0 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/collection-chooser-toolbar.tsx @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { ToolbarGroup, ToolbarButton } from '@wordpress/components'; + +const CollectionChooserToolbar = ( props: { + openCollectionSelectionModal: () => void; +} ) => { + return ( + + + { __( 'Choose collection', 'woocommerce' ) } + + + ); +}; + +export default CollectionChooserToolbar; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/display-layout-toolbar.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/display-layout-toolbar.tsx index f582c79780c..05239391a55 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/display-layout-toolbar.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/display-layout-toolbar.tsx @@ -9,12 +9,12 @@ import { list, grid } from '@wordpress/icons'; * Internal dependencies */ import { - DisplayLayoutToolbarProps, + DisplayLayoutControlProps, ProductCollectionDisplayLayout, LayoutOptions, } from '../../types'; -const DisplayLayoutToolbar = ( props: DisplayLayoutToolbarProps ) => { +const DisplayLayoutToolbar = ( props: DisplayLayoutControlProps ) => { const { type, columns, shrinkColumns } = props.displayLayout; const setDisplayLayout = ( displayLayout: ProductCollectionDisplayLayout diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx index 0d87fb53ec5..e73be329b9f 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx @@ -1,27 +1,22 @@ /** * External dependencies */ -import type { BlockEditProps } from '@wordpress/blocks'; -import { useMemo, useState } from '@wordpress/element'; +import { useMemo } from '@wordpress/element'; import { BlockControls } from '@wordpress/block-editor'; /** * Internal dependencies */ import { setQueryAttribute } from '../../utils'; -import { ProductCollectionAttributes } from '../../types'; import DisplaySettingsToolbar from './display-settings-toolbar'; import DisplayLayoutToolbar from './display-layout-toolbar'; -import PatternChooserToolbar from './pattern-chooser-toolbar'; -import PatternSelectionModal from './pattern-selection-modal'; +import CollectionChooserToolbar from './collection-chooser-toolbar'; +import type { ProductCollectionEditComponentProps } from '../../types'; export default function ToolbarControls( - props: BlockEditProps< ProductCollectionAttributes > + props: ProductCollectionEditComponentProps ) { - const [ isPatternSelectionModalOpen, setIsPatternSelectionModalOpen ] = - useState( false ); - - const { attributes, clientId, setAttributes } = props; + const { attributes, openCollectionSelectionModal, setAttributes } = props; const { query, displayLayout } = attributes; const setQueryAttributeBind = useMemo( @@ -31,10 +26,8 @@ export default function ToolbarControls( return ( - - setIsPatternSelectionModalOpen( true ) - } + { ! query.inherit && ( <> @@ -48,15 +41,6 @@ export default function ToolbarControls( /> ) } - { isPatternSelectionModalOpen && ( - - setIsPatternSelectionModalOpen( false ) - } - /> - ) } ); } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-chooser-toolbar.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-chooser-toolbar.tsx deleted file mode 100644 index 30c6c7a8fc7..00000000000 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-chooser-toolbar.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { ToolbarGroup, ToolbarButton } from '@wordpress/components'; - -const DisplayLayoutControl = ( props: { - openPatternSelectionModal: () => void; -} ) => { - return ( - - - { __( 'Choose pattern', 'woocommerce' ) } - - - ); -}; - -export default DisplayLayoutControl; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-selection-modal.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-selection-modal.tsx deleted file mode 100644 index 72e6fd727c0..00000000000 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-selection-modal.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { Modal } from '@wordpress/components'; -import { - store as blockEditorStore, - __experimentalBlockPatternsList as BlockPatternsList, -} from '@wordpress/block-editor'; -import { type BlockInstance, cloneBlock } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { ProductCollectionQuery } from '../../types'; - -const blockName = 'woocommerce/product-collection'; - -const DisplayLayoutControl = ( props: { - clientId: string; - query: ProductCollectionQuery; - closePatternSelectionModal: () => void; -} ) => { - const { clientId, query } = props; - const { replaceBlock, selectBlock } = useDispatch( blockEditorStore ); - - const transformBlock = ( block: BlockInstance ): BlockInstance => { - const newInnerBlocks = block.innerBlocks.map( transformBlock ); - if ( block.name === blockName ) { - const { perPage, offset, pages } = block.attributes.query; - const newQuery = { - ...query, - perPage, - offset, - pages, - }; - return cloneBlock( block, { query: newQuery }, newInnerBlocks ); - } - return cloneBlock( block, {}, newInnerBlocks ); - }; - - const blockPatterns = useSelect( - ( select ) => { - const { getBlockRootClientId, getPatternsByBlockTypes } = - select( blockEditorStore ); - const rootClientId = getBlockRootClientId( clientId ); - return getPatternsByBlockTypes( blockName, rootClientId ); - }, - [ blockName, clientId ] - ); - - const onClickPattern = ( pattern, blocks: BlockInstance[] ) => { - const newBlocks = blocks.map( transformBlock ); - - replaceBlock( clientId, newBlocks ); - selectBlock( newBlocks[ 0 ].clientId ); - }; - - return ( - -
- -
-
- ); -}; - -export default DisplayLayoutControl; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/index.tsx index 62b9df69dcd..61d7919ec53 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/index.tsx @@ -12,6 +12,7 @@ import save from './save'; import icon from './icon'; import registerProductSummaryVariation from './variations/elements/product-summary'; import registerProductTitleVariation from './variations/elements/product-title'; +import registerCollections from './collections'; registerBlockType( metadata, { icon, @@ -20,3 +21,4 @@ registerBlockType( metadata, { } ); registerProductSummaryVariation(); registerProductTitleVariation(); +registerCollections(); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/inner-blocks/no-results/block.json b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/inner-blocks/no-results/block.json index 68104d84d09..5b7fec9c96c 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/inner-blocks/no-results/block.json +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/inner-blocks/no-results/block.json @@ -1,16 +1,16 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 3, - "name": "woocommerce/product-collection-no-results", - "title": "No results", - "version": "1.0.0", - "category": "woocommerce", - "description": "The contents of this block will display when there are no products found.", - "textdomain": "woocommerce", - "keywords": [ "Product Collection" ], + "apiVersion": 3, + "name": "woocommerce/product-collection-no-results", + "title": "No results", + "version": "1.0.0", + "category": "woocommerce", + "description": "The contents of this block will display when there are no products found.", + "textdomain": "woocommerce", + "keywords": [ "Product Collection" ], "usesContext": [ "queryId", "query" ], "ancestor": [ "woocommerce/product-collection" ], - "supports": { + "supports": { "align": true, "reusable": false, "html": false, diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts index 82713edc84b..2a1f3e79793 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts @@ -1,7 +1,8 @@ /** * External dependencies */ -import { AttributeMetadata } from '@woocommerce/types'; +import type { BlockEditProps } from '@wordpress/blocks'; +import { type AttributeMetadata } from '@woocommerce/types'; export interface ProductCollectionAttributes { query: ProductCollectionQuery; @@ -15,6 +16,8 @@ export interface ProductCollectionAttributes { displayLayout: ProductCollectionDisplayLayout; tagName: string; convertedFromProducts: boolean; + collection?: string; + hideControls: FilterName[]; } export enum LayoutOptions { @@ -80,6 +83,11 @@ export interface ProductCollectionQuery { priceRange?: undefined | PriceRange; } +export type ProductCollectionEditComponentProps = + BlockEditProps< ProductCollectionAttributes > & { + openCollectionSelectionModal: () => void; + }; + export type TProductCollectionOrder = 'asc' | 'desc'; export type TProductCollectionOrderBy = | 'date' @@ -87,7 +95,7 @@ export type TProductCollectionOrderBy = | 'popularity' | 'rating'; -export type DisplayLayoutToolbarProps = { +export type DisplayLayoutControlProps = { displayLayout: ProductCollectionDisplayLayout; setAttributes: ( attrs: Partial< ProductCollectionAttributes > ) => void; }; @@ -95,3 +103,29 @@ export type QueryControlProps = { query: ProductCollectionQuery; setQueryAttribute: ( attrs: Partial< ProductCollectionQuery > ) => void; }; + +export enum CoreCollectionNames { + PRODUCT_CATALOG = 'woocommerce/product-collection/product-catalog', + CUSTOM = 'woocommerce/product-collection/custom', + BEST_SELLERS = 'woocommerce/product-collection/best-sellers', + FEATURED = 'woocommerce/product-collection/featured', + NEW_ARRIVALS = 'woocommerce/product-collection/new-arrivals', + ON_SALE = 'woocommerce/product-collection/on-sale', + TOP_RATED = 'woocommerce/product-collection/top-rated', +} + +export enum CoreFilterNames { + ATTRIBUTES = 'attributes', + CREATED = 'created', + FEATURED = 'featured', + HAND_PICKED = 'hand-picked', + INHERIT = 'inherit', + KEYWORD = 'keyword', + ON_SALE = 'on-sale', + ORDER = 'order', + STOCK_STATUS = 'stock-status', + TAXONOMY = 'taxonomy', +} + +export type CollectionName = CoreCollectionNames | string; +export type FilterName = CoreFilterNames | string; diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/compatibility-layer.block_theme.side_effects.spec.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/compatibility-layer.block_theme.side_effects.spec.ts index af5db6b3377..3ae2b4daac2 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/compatibility-layer.block_theme.side_effects.spec.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/compatibility-layer.block_theme.side_effects.spec.ts @@ -92,7 +92,6 @@ const test = base.extend< { pageObject: ProductCollectionPage } >( { templateApiUtils, editorUtils, } ); - await pageObject.createNewPostAndInsertBlock(); await use( pageObject ); }, } ); diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts index ea28c52dd9e..fd438c77712 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts @@ -237,7 +237,7 @@ test.describe( 'Product Collection', () => { test( 'Inherit query from template should work as expected in Product Catalog template', async ( { pageObject, } ) => { - await pageObject.goToProductCatalogAndInsertBlock(); + await pageObject.goToProductCatalogAndInsertCollection(); const sidebarSettings = await pageObject.locateSidebarSettings(); @@ -292,6 +292,7 @@ test.describe( 'Product Collection', () => { test( 'Toolbar -> Items per page, offset & max page to show', async ( { pageObject, } ) => { + await pageObject.clickDisplaySettings(); await pageObject.setDisplaySettings( { itemsPerPage: 3, offset: 0, @@ -382,4 +383,204 @@ test.describe( 'Product Collection', () => { ); } ); } ); + + test.describe( 'Collections', () => { + test( 'New Arrivals Collection can be added and displays proper products', async ( { + pageObject, + } ) => { + await pageObject.createNewPostAndInsertBlock( 'newArrivals' ); + + const newArrivalsProducts = [ + 'WordPress Pennant', + 'Logo Collection', + 'Beanie with Logo', + 'T-Shirt with Logo', + 'Single', + ]; + + await expect( pageObject.products ).toHaveCount( 5 ); + await expect( pageObject.productTitles ).toHaveText( + newArrivalsProducts + ); + + await pageObject.publishAndGoToFrontend(); + + await expect( pageObject.products ).toHaveCount( 5 ); + } ); + + // When creating reviews programmatically the ratings are not propagated + // properly so products order by rating is undeterministic in test env. + // eslint-disable-next-line playwright/no-skipped-test + test.skip( 'Top Rated Collection can be added and displays proper products', async ( { + pageObject, + } ) => { + await pageObject.createNewPostAndInsertBlock( 'topRated' ); + + const topRatedProducts = [ + 'V Neck T Shirt', + 'Hoodie', + 'Hoodie with Logo', + 'T-Shirt', + 'Beanie', + ]; + + await expect( pageObject.products ).toHaveCount( 5 ); + await expect( pageObject.productTitles ).toHaveText( + topRatedProducts + ); + + await pageObject.publishAndGoToFrontend(); + + await expect( pageObject.products ).toHaveCount( 5 ); + } ); + + // There's no orders in test env so the order of Best Sellers + // is undeterministic in test env. Requires further work. + // eslint-disable-next-line playwright/no-skipped-test + test.skip( 'Best Sellers Collection can be added and displays proper products', async ( { + pageObject, + } ) => { + await pageObject.createNewPostAndInsertBlock( 'bestSellers' ); + + const bestSellersProducts = [ + 'Album', + 'Hoodie', + 'Single', + 'Hoodie with Logo', + 'T-Shirt with Logo', + ]; + + await expect( pageObject.products ).toHaveCount( 5 ); + await expect( pageObject.productTitles ).toHaveText( + bestSellersProducts + ); + + await pageObject.publishAndGoToFrontend(); + + await expect( pageObject.products ).toHaveCount( 5 ); + } ); + + test( 'On Sale Collection can be added and displays proper products', async ( { + pageObject, + } ) => { + await pageObject.createNewPostAndInsertBlock( 'onSale' ); + + const onSaleProducts = [ + 'Beanie', + 'Beanie with Logo', + 'Belt', + 'Cap', + 'Hoodie', + ]; + + await expect( pageObject.products ).toHaveCount( 5 ); + await expect( pageObject.productTitles ).toHaveText( + onSaleProducts + ); + + await pageObject.publishAndGoToFrontend(); + + await expect( pageObject.products ).toHaveCount( 5 ); + } ); + + test( 'Featured Collection can be added and displays proper products', async ( { + pageObject, + } ) => { + await pageObject.createNewPostAndInsertBlock( 'featured' ); + + const featuredProducts = [ + 'Cap', + 'Hoodie with Zipper', + 'Sunglasses', + 'V-Neck T-Shirt', + ]; + + await expect( pageObject.products ).toHaveCount( 4 ); + await expect( pageObject.productTitles ).toHaveText( + featuredProducts + ); + + await pageObject.publishAndGoToFrontend(); + + await expect( pageObject.products ).toHaveCount( 4 ); + } ); + + test( "Product Catalog Collection can be added in post and doesn't inherit query from template", async ( { + pageObject, + } ) => { + await pageObject.createNewPostAndInsertBlock( 'productCatalog' ); + + const sidebarSettings = await pageObject.locateSidebarSettings(); + const input = sidebarSettings.locator( + `${ SELECTORS.inheritQueryFromTemplateControl } input` + ); + + await expect( input ).toBeHidden(); + await expect( pageObject.products ).toHaveCount( 9 ); + + await pageObject.publishAndGoToFrontend(); + + await expect( pageObject.products ).toHaveCount( 9 ); + } ); + + test( 'Product Catalog Collection can be added in product archive and inherits query from template', async ( { + pageObject, + } ) => { + await pageObject.goToProductCatalogAndInsertCollection( + 'productCatalog' + ); + + const sidebarSettings = await pageObject.locateSidebarSettings(); + const input = sidebarSettings.locator( + `${ SELECTORS.inheritQueryFromTemplateControl } input` + ); + + await expect( input ).toBeChecked(); + } ); + + test.describe( 'Have hidden implementation in UI', () => { + test( 'New Arrivals', async ( { pageObject } ) => { + await pageObject.createNewPostAndInsertBlock( 'newArrivals' ); + const input = await pageObject.getOrderByElement(); + + await expect( input ).toBeHidden(); + } ); + + test( 'Top Rated', async ( { pageObject } ) => { + await pageObject.createNewPostAndInsertBlock( 'topRated' ); + const input = await pageObject.getOrderByElement(); + + await expect( input ).toBeHidden(); + } ); + + test( 'Best Sellers', async ( { pageObject } ) => { + await pageObject.createNewPostAndInsertBlock( 'bestSellers' ); + const input = await pageObject.getOrderByElement(); + + await expect( input ).toBeHidden(); + } ); + + test( 'On Sale', async ( { pageObject } ) => { + await pageObject.createNewPostAndInsertBlock( 'onSale' ); + const sidebarSettings = + await pageObject.locateSidebarSettings(); + const input = sidebarSettings.getByLabel( + SELECTORS.onSaleControlLabel + ); + + await expect( input ).toBeHidden(); + } ); + + test( 'Featured', async ( { pageObject } ) => { + await pageObject.createNewPostAndInsertBlock( 'featured' ); + const sidebarSettings = + await pageObject.locateSidebarSettings(); + const input = sidebarSettings.getByLabel( + SELECTORS.featuredControlLabel + ); + + await expect( input ).toBeHidden(); + } ); + } ); + } ); } ); diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts index f7145afe73c..1019b437f0f 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts @@ -4,7 +4,6 @@ import { Locator, Page } from '@playwright/test'; import { TemplateApiUtils, EditorUtils } from '@woocommerce/e2e-utils'; import { Editor, Admin } from '@wordpress/e2e-test-utils-playwright'; -import { BlockRepresentation } from '@wordpress/e2e-test-utils-playwright/build-types/editor/insert-block'; export const SELECTORS = { productTemplate: '.wc-block-product-template', @@ -27,6 +26,7 @@ export const SELECTORS = { onFrontend: '.wp-block-query-pagination', }, onSaleControlLabel: 'Show only products on sale', + featuredControlLabel: 'Show only featured products', inheritQueryFromTemplateControl: '.wc-block-product-collection__inherit-query-control', shrinkColumnsToFit: 'Responsive', @@ -34,6 +34,24 @@ export const SELECTORS = { productSearchButton: '.wp-block-search__button wp-element-button', }; +type Collections = + | 'newArrivals' + | 'topRated' + | 'bestSellers' + | 'onSale' + | 'featured' + | 'productCatalog'; + +const collectionToButtonNameMap = { + newArrivals: 'New Arrivals Recommend your newest products.', + topRated: 'Top Rated Recommend products with the highest review ratings.', + bestSellers: 'Best Sellers Recommend your best-selling products.', + onSale: 'On Sale Highlight products that are currently on sale.', + featured: 'Featured Showcase your featured products.', + productCatalog: + 'Product Catalog Display all products in your catalog. Results can (change to) match the current template, page, or search term.', +}; + class ProductCollectionPage { private BLOCK_NAME = 'woocommerce/product-collection'; private page: Page; @@ -69,11 +87,33 @@ class ProductCollectionPage { this.editorUtils = editorUtils; } - async createNewPostAndInsertBlock() { + async chooseCollectionInPost( collection?: Collections ) { + const buttonName = collection + ? collectionToButtonNameMap[ collection ] + : collectionToButtonNameMap.productCatalog; + + await this.admin.page + .getByRole( 'button', { name: buttonName } ) + .click(); + } + + async chooseCollectionInTemplate( collection?: Collections ) { + const buttonName = collection + ? collectionToButtonNameMap[ collection ] + : collectionToButtonNameMap.productCatalog; + + await this.admin.page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .getByRole( 'button', { name: buttonName } ) + .click(); + } + + async createNewPostAndInsertBlock( collection?: Collections ) { await this.admin.createNewPost( { legacyCanvas: true } ); await this.editor.insertBlock( { name: this.BLOCK_NAME, } ); + await this.chooseCollectionInPost( collection ); await this.refreshLocators( 'editor' ); } @@ -85,19 +125,22 @@ class ProductCollectionPage { await this.refreshLocators( 'frontend' ); } - async replaceProductsWithProductCollectionInTemplate( template: string ) { + async replaceProductsWithProductCollectionInTemplate( + template: string, + collection?: Collections + ) { await this.templateApiUtils.revertTemplate( template ); await this.admin.visitSiteEditor( { postId: template, postType: 'wp_template', } ); + await this.editorUtils.waitForSiteEditorFinishLoading(); await this.editorUtils.enterEditMode(); - await this.editorUtils.replaceBlockByBlockName( 'core/query', 'woocommerce/product-collection' ); - + await this.chooseCollectionInTemplate( collection ); await this.editor.saveSiteEditorEntities(); } @@ -105,11 +148,7 @@ class ProductCollectionPage { await this.page.goto( `/shop` ); } - async goToProductCatalogAndInsertBlock( - block: BlockRepresentation = { - name: this.BLOCK_NAME, - } - ) { + async goToProductCatalogAndInsertCollection( collection?: Collections ) { await this.templateApiUtils.revertTemplate( 'woocommerce/woocommerce//archive-product' ); @@ -118,10 +157,10 @@ class ProductCollectionPage { postId: 'woocommerce/woocommerce//archive-product', postType: 'wp_template', } ); - + await this.editorUtils.waitForSiteEditorFinishLoading(); await this.editor.canvas.click( 'body' ); - - await this.editor.insertBlock( block ); + await this.editor.insertBlock( { name: this.BLOCK_NAME } ); + await this.chooseCollectionInTemplate( collection ); await this.editor.openDocumentSettingsSidebar(); await this.editor.saveSiteEditorEntities(); } @@ -181,6 +220,18 @@ class ProductCollectionPage { await this.refreshLocators( 'editor' ); } + async getOrderByElement() { + const sidebarSettings = await this.locateSidebarSettings(); + return sidebarSettings.getByRole( 'combobox', { + name: 'Order by', + } ); + } + + async getOrderBy() { + const orderByComboBox = await this.getOrderByElement(); + return await orderByComboBox.inputValue(); + } + async setShowOnlyProductsOnSale( { onSale, @@ -246,16 +297,7 @@ class ProductCollectionPage { await this.refreshLocators( 'editor' ); } - async setDisplaySettings( { - itemsPerPage, - offset, - maxPageToShow, - }: { - itemsPerPage: number; - offset: number; - maxPageToShow: number; - isOnFrontend?: boolean; - } ) { + async clickDisplaySettings() { // Select the block, so that toolbar is visible. const block = this.page .locator( `[data-type="${ this.BLOCK_NAME }"]` ) @@ -266,7 +308,18 @@ class ProductCollectionPage { await this.page .getByRole( 'button', { name: 'Display settings' } ) .click(); + } + async setDisplaySettings( { + itemsPerPage, + offset, + maxPageToShow, + }: { + itemsPerPage: number; + offset: number; + maxPageToShow: number; + isOnFrontend?: boolean; + } ) { // Set the values. const displaySettingsContainer = this.page.locator( '.wc-block-editor-product-collection__display-settings' @@ -364,6 +417,10 @@ class ProductCollectionPage { return this.page.getByTestId( testId ); } + async getCollectionHeading() { + return this.page.getByRole( 'heading' ); + } + /** * Private methods to be used by the class. */ diff --git a/plugins/woocommerce-blocks/tests/e2e/utils/editor/editor-utils.page.ts b/plugins/woocommerce-blocks/tests/e2e/utils/editor/editor-utils.page.ts index 078d726f34d..b6f53c8993f 100644 --- a/plugins/woocommerce-blocks/tests/e2e/utils/editor/editor-utils.page.ts +++ b/plugins/woocommerce-blocks/tests/e2e/utils/editor/editor-utils.page.ts @@ -232,6 +232,17 @@ export class EditorUtils { return firstBlockIndex < secondBlockIndex; } + async waitForSiteEditorFinishLoading() { + await this.page + .frameLocator( 'iframe[title="Editor canvas"i]' ) + .locator( 'body > *' ) + .first() + .waitFor(); + await this.page + .locator( '.edit-site-canvas-loader' ) + .waitFor( { state: 'hidden' } ); + } + async setLayoutOption( option: | 'Align Top' diff --git a/plugins/woocommerce/changelog/add-42224-new-flow-adding-product-collection b/plugins/woocommerce/changelog/add-42224-new-flow-adding-product-collection new file mode 100644 index 00000000000..11c9bb36798 --- /dev/null +++ b/plugins/woocommerce/changelog/add-42224-new-flow-adding-product-collection @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Product Collection: introduce the new flow of adding Product Collection block along with a preconfigured set of Collections: New Arrivals, Top Rated, Best Selling, On Sale, Featured