diff --git a/assets/js/blocks/product-collection/block.json b/assets/js/blocks/product-collection/block.json
index 12bbea73722..5023420b314 100644
--- a/assets/js/blocks/product-collection/block.json
+++ b/assets/js/blocks/product-collection/block.json
@@ -24,6 +24,9 @@
"convertedFromProducts": {
"type": "boolean",
"default": false
+ },
+ "collection": {
+ "type": "string"
}
},
"providesContext": {
diff --git a/assets/js/blocks/product-collection/collections/README.md b/assets/js/blocks/product-collection/collections/README.md
new file mode 100644
index 00000000000..a3b75e2c96e
--- /dev/null
+++ b/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 public API._
+
+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 = {
+ ...;
+ unchangeableFilters: FilterName[];
+}
+```
+
+## Registering Collection
+
+To register collection import it in `./index.ts` file and add to the `collectionsToRegister` array.
diff --git a/assets/js/blocks/product-collection/collections/best-sellers.tsx b/assets/js/blocks/product-collection/collections/best-sellers.tsx
new file mode 100644
index 00000000000..e605d4a8835
--- /dev/null
+++ b/assets/js/blocks/product-collection/collections/best-sellers.tsx
@@ -0,0 +1,67 @@
+/**
+ * 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', 'woo-gutenberg-products-block' ),
+ icon: ( ) as BlockIcon,
+ description: __(
+ 'Recommend your best-selling products.',
+ 'woo-gutenberg-products-block'
+ ),
+ keywords: [ 'best selling' ],
+ scope: [],
+ unchangeableFilters: [ CoreFilterNames.INHERIT, CoreFilterNames.ORDER ],
+};
+
+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,
+};
+
+const heading: InnerBlockTemplate = [
+ 'core/heading',
+ {
+ textAlign: 'center',
+ level: 2,
+ content: __( 'Best selling products', 'woo-gutenberg-products-block' ),
+ style: { spacing: { margin: { bottom: '1rem' } } },
+ },
+];
+
+const innerBlocks: InnerBlockTemplate[] = [
+ heading,
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+];
+
+export default {
+ ...collection,
+ attributes,
+ innerBlocks,
+};
diff --git a/assets/js/blocks/product-collection/collections/featured.tsx b/assets/js/blocks/product-collection/collections/featured.tsx
new file mode 100644
index 00000000000..744156187c8
--- /dev/null
+++ b/assets/js/blocks/product-collection/collections/featured.tsx
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import type {
+ BlockAttributes,
+ 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', 'woo-gutenberg-products-block' ),
+ icon: ( ) as BlockIcon,
+ description: __(
+ 'Showcase your featured products.',
+ 'woo-gutenberg-products-block'
+ ),
+ keywords: [],
+ scope: [],
+ unchangeableFilters: [ CoreFilterNames.INHERIT, CoreFilterNames.FEATURED ],
+};
+
+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,
+};
+
+const heading: [ string, BlockAttributes?, InnerBlockTemplate[]? ] = [
+ 'core/heading',
+ {
+ textAlign: 'center',
+ level: 2,
+ content: __( 'Featured products', 'woo-gutenberg-products-block' ),
+ style: { spacing: { margin: { bottom: '1rem' } } },
+ },
+];
+
+const innerBlocks: InnerBlockTemplate[] = [
+ heading,
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+];
+
+export default {
+ ...collection,
+ attributes,
+ innerBlocks,
+};
diff --git a/assets/js/blocks/product-collection/collections/index.tsx b/assets/js/blocks/product-collection/collections/index.tsx
new file mode 100644
index 00000000000..222e9a27ea2
--- /dev/null
+++ b/assets/js/blocks/product-collection/collections/index.tsx
@@ -0,0 +1,72 @@
+/**
+ * External dependencies
+ */
+import {
+ type BlockVariation,
+ registerBlockVariation,
+ BlockAttributes,
+} from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import { CollectionName, FilterName } from '../types';
+import blockJson from '../block.json';
+import productCatalog from './product-catalog';
+import newArrivals from './new-arrivals';
+import topRated from './top-rated';
+import bestSellers from './best-sellers';
+import onSale from './on-sale';
+import featured from './featured';
+
+export const collections = {
+ productCatalog,
+ newArrivals,
+ topRated,
+ bestSellers,
+ onSale,
+ featured,
+};
+
+const collectionsToRegister: BlockVariation[] = [
+ featured,
+ topRated,
+ onSale,
+ bestSellers,
+ newArrivals,
+];
+
+export const registerCollections = () => {
+ collectionsToRegister.forEach( ( collection ) => {
+ const isActive = (
+ blockAttrs: BlockAttributes,
+ variationAttributes: BlockAttributes
+ ) => {
+ return blockAttrs.collection === variationAttributes.collection;
+ };
+
+ registerBlockVariation( blockJson.name, {
+ isActive,
+ ...collection,
+ } );
+ } );
+};
+
+export const getCollectionByName = ( collectionName: CollectionName ) => {
+ return Object.values( collections ).find(
+ ( { name } ) => name === collectionName
+ );
+};
+
+export const getUnchangeableFilters = (
+ collectionName?: CollectionName
+): FilterName[] => {
+ if ( ! collectionName ) {
+ return [];
+ }
+
+ const collection = getCollectionByName( collectionName );
+ return collection ? collection.unchangeableFilters : [];
+};
+
+export default registerCollections;
diff --git a/assets/js/blocks/product-collection/collections/new-arrivals.tsx b/assets/js/blocks/product-collection/collections/new-arrivals.tsx
new file mode 100644
index 00000000000..3765fe08c5f
--- /dev/null
+++ b/assets/js/blocks/product-collection/collections/new-arrivals.tsx
@@ -0,0 +1,71 @@
+/**
+ * External dependencies
+ */
+import type {
+ BlockAttributes,
+ 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', 'woo-gutenberg-products-block' ),
+ icon: ( ) as BlockIcon,
+ description: __(
+ 'Recommend your newest products.',
+ 'woo-gutenberg-products-block'
+ ),
+ keywords: [ 'newest products' ],
+ scope: [],
+ unchangeableFilters: [ CoreFilterNames.INHERIT, CoreFilterNames.ORDER ],
+};
+
+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,
+};
+
+const heading: [ string, BlockAttributes?, InnerBlockTemplate[]? ] = [
+ 'core/heading',
+ {
+ textAlign: 'center',
+ level: 2,
+ content: __( 'New arrivals', 'woo-gutenberg-products-block' ),
+ style: { spacing: { margin: { bottom: '1rem' } } },
+ },
+];
+
+const innerBlocks: InnerBlockTemplate[] = [
+ heading,
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+];
+
+export default {
+ ...collection,
+ attributes,
+ innerBlocks,
+};
diff --git a/assets/js/blocks/product-collection/collections/on-sale.tsx b/assets/js/blocks/product-collection/collections/on-sale.tsx
new file mode 100644
index 00000000000..95e967d4750
--- /dev/null
+++ b/assets/js/blocks/product-collection/collections/on-sale.tsx
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import type {
+ BlockAttributes,
+ 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', 'woo-gutenberg-products-block' ),
+ icon: ( ) as BlockIcon,
+ description: __(
+ 'Highlight products that are currently on sale.',
+ 'woo-gutenberg-products-block'
+ ),
+ keywords: [],
+ scope: [],
+ unchangeableFilters: [ CoreFilterNames.INHERIT, CoreFilterNames.ON_SALE ],
+};
+
+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,
+};
+
+const heading: [ string, BlockAttributes?, InnerBlockTemplate[]? ] = [
+ 'core/heading',
+ {
+ textAlign: 'center',
+ level: 2,
+ content: __( 'On sale products', 'woo-gutenberg-products-block' ),
+ style: { spacing: { margin: { bottom: '1rem' } } },
+ },
+];
+
+const innerBlocks: InnerBlockTemplate[] = [
+ heading,
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+];
+
+export default {
+ ...collection,
+ attributes,
+ innerBlocks,
+};
diff --git a/assets/js/blocks/product-collection/collections/product-catalog.tsx b/assets/js/blocks/product-collection/collections/product-catalog.tsx
new file mode 100644
index 00000000000..aa3921192b6
--- /dev/null
+++ b/assets/js/blocks/product-collection/collections/product-catalog.tsx
@@ -0,0 +1,42 @@
+/**
+ * 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_PRODUCT_TEMPLATE,
+} from '../constants';
+import { CoreCollectionNames } from '../types';
+
+const collection = {
+ name: CoreCollectionNames.PRODUCT_CATALOG,
+ title: __( 'Product Catalog', 'woo-gutenberg-products-block' ),
+ icon: ( ) as BlockIcon,
+ description:
+ 'Display all products. Results may be limited by the current template context.',
+ keywords: [ 'all products' ],
+ scope: [],
+ unchangeableFilters: [],
+};
+
+const attributes = {
+ ...DEFAULT_ATTRIBUTES,
+ query: {
+ ...DEFAULT_ATTRIBUTES.query,
+ inherit: true,
+ },
+};
+
+const innerBlocks: InnerBlockTemplate[] = [ INNER_BLOCKS_PRODUCT_TEMPLATE ];
+
+export default {
+ ...collection,
+ attributes,
+ innerBlocks,
+};
diff --git a/assets/js/blocks/product-collection/collections/top-rated.tsx b/assets/js/blocks/product-collection/collections/top-rated.tsx
new file mode 100644
index 00000000000..9b473c028fa
--- /dev/null
+++ b/assets/js/blocks/product-collection/collections/top-rated.tsx
@@ -0,0 +1,71 @@
+/**
+ * External dependencies
+ */
+import type {
+ BlockAttributes,
+ 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', 'woo-gutenberg-products-block' ),
+ icon: ( ) as BlockIcon,
+ description: __(
+ 'Recommend products with the highest review ratings.',
+ 'woo-gutenberg-products-block'
+ ),
+ keywords: [],
+ scope: [],
+ unchangeableFilters: [ CoreFilterNames.INHERIT, CoreFilterNames.ORDER ],
+};
+
+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,
+};
+
+const heading: [ string, BlockAttributes?, InnerBlockTemplate[]? ] = [
+ 'core/heading',
+ {
+ textAlign: 'center',
+ level: 2,
+ content: __( 'Top rated products', 'woo-gutenberg-products-block' ),
+ style: { spacing: { margin: { bottom: '1rem' } } },
+ },
+];
+
+const innerBlocks: InnerBlockTemplate[] = [
+ heading,
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+];
+
+export default {
+ ...collection,
+ attributes,
+ innerBlocks,
+};
diff --git a/assets/js/blocks/product-collection/edit/collection-selection-modal.tsx b/assets/js/blocks/product-collection/edit/collection-selection-modal.tsx
new file mode 100644
index 00000000000..eba355fd9a6
--- /dev/null
+++ b/assets/js/blocks/product-collection/edit/collection-selection-modal.tsx
@@ -0,0 +1,188 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useState } from '@wordpress/element';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { Modal, Button } from '@wordpress/components';
+import {
+ // @ts-expect-error Type definitions for this function are missing in Guteberg
+ store as blocksStore,
+ createBlock,
+ // @ts-expect-error Type definitions for this function are missing in Guteberg
+ createBlocksFromInnerBlocksTemplate,
+} from '@wordpress/blocks';
+/**
+ * External dependencies
+ */
+import { store as blockEditorStore } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductCollectionAttributes } from '../types';
+import { getDefaultProductCollection } from '../constants';
+import blockJson from '../block.json';
+import { collections } from '../collections';
+
+type CollectionButtonProps = {
+ active: boolean;
+ title: string;
+ icon: string;
+ description: string;
+ onClick: () => void;
+};
+
+const CollectionButton = ( {
+ active,
+ title,
+ icon,
+ description,
+ onClick,
+}: CollectionButtonProps ) => {
+ const variant = active ? 'primary' : 'secondary';
+
+ return (
+
+ );
+};
+
+const getDefaultChosenCollection = (
+ attributes: ProductCollectionAttributes,
+ // @ts-expect-error Type definitions are missing
+ // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wordpress__blocks/store/selectors.d.ts
+ blockCollections
+) => {
+ // If `attributes.query` is truthy, that means Product Collection was already
+ // configured. So it's either a collection or we need to return defaultQuery
+ // collection name;
+ if ( attributes.query ) {
+ return attributes.collection || collections.productCatalog.name;
+ }
+
+ // Otherwise it should be the first available choice. We control collections
+ // so there's always at least one available.
+ return blockCollections.length ? blockCollections[ 0 ].name : '';
+};
+
+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 );
+
+ // Get Collections
+ const blockCollections = [
+ collections.productCatalog,
+ ...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 );
+ }, [] ),
+ ];
+
+ // Prepare Collections
+ const defaultChosenCollection = getDefaultChosenCollection(
+ attributes,
+ blockCollections
+ );
+
+ const [ chosenCollectionName, selectCollectionName ] = useState(
+ defaultChosenCollection
+ );
+
+ const applyCollection = () => {
+ // Case 1: Merchant has chosen Default Query. In that case we create defaultProductCollection
+ if (
+ chosenCollectionName ===
+ 'woocommerce-blocks/product-collection/default-query'
+ ) {
+ const defaultProductCollection = getDefaultProductCollection();
+ replaceBlock( clientId, defaultProductCollection );
+ return;
+ }
+
+ // Case 2: Merchant has chosen another Collection
+ const chosenCollection = blockCollections.find(
+ ( { name }: { name: string } ) => name === chosenCollectionName
+ );
+
+ const newBlock = createBlock(
+ blockJson.name,
+ chosenCollection.attributes,
+ createBlocksFromInnerBlocksTemplate( chosenCollection.innerBlocks )
+ );
+
+ replaceBlock( clientId, newBlock );
+ };
+
+ return (
+
+
+
+ { __(
+ "Pick what products are shown. Don't worry, you can switch and tweak this collection any time.",
+ 'woo-gutenberg-products-block'
+ ) }
+
+
+ { blockCollections.map(
+ ( { name, title, icon, description } ) => (
+ selectCollectionName( name ) }
+ />
+ )
+ ) }
+
+
+
+
+
+
+
+ );
+};
+
+export default PatternSelectionModal;
diff --git a/assets/js/blocks/product-collection/edit/editor.scss b/assets/js/blocks/product-collection/edit/editor.scss
index 796c7a23fc9..1b3a6b3b1cc 100644
--- a/assets/js/blocks/product-collection/edit/editor.scss
+++ b/assets/js/blocks/product-collection/edit/editor.scss
@@ -10,17 +10,79 @@
}
}
-.wc-blocks-product-collection__selection-modal {
- .block-editor-block-patterns-list {
- column-count: 3;
- column-gap: $grid-unit-30;
+.wc-blocks-product-collection__placeholder,
+.wc-blocks-product-collection__modal {
+ .wc-blocks-product-collection__selection-subtitle {
+ margin-bottom: $gap-large;
+ }
- @include breakpoint("<1280px") {
- column-count: 2;
- }
+ .wc-blocks-product-collection__collections-section {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-auto-rows: 1fr;
+ gap: $gap-small;
+ margin: $gap-large auto;
+ max-width: 1000px;
@include breakpoint("<782px") {
- column-count: 1;
+ grid-template-columns: 1fr 1fr;
+
+ .wc-blocks-product-collection__collection-button {
+ padding: $gap-smallest;
+
+ .wc-blocks-product-collection__collection-button-text {
+ padding: 0 0 0 $gap-smallest;
+ }
+ }
+ }
+ }
+
+ .wc-blocks-product-collection__collection-button {
+ color: inherit;
+ display: flex;
+ align-items: flex-start;
+ height: auto;
+ border: 1px solid $input-border-gray;
+ border-radius: $universal-border-radius;
+ box-sizing: border-box;
+ box-shadow: none;
+ padding: $gap-smallest $gap-small;
+ margin: 0;
+
+ &.is-primary {
+ border: 2px solid var(--wp-admin-theme-color-darker-10, #3858e9);
+ color: var(--wp-admin-theme-color-darker-10);
+ background-color: #fff;
+ margin: -1px;
+
+ &:hover {
+ background-color: #fff;
+ color: var(--wp-admin-theme-color-darker-10);
+ }
}
+
+ .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/assets/js/blocks/product-collection/edit/index.tsx b/assets/js/blocks/product-collection/edit/index.tsx
index 9dae873a53c..3c7b46ac530 100644
--- a/assets/js/blocks/product-collection/edit/index.tsx
+++ b/assets/js/blocks/product-collection/edit/index.tsx
@@ -1,74 +1,53 @@
/**
* 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 PatternSelectionModal 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 blockProps = useBlockProps();
- const innerBlocksProps = useInnerBlocksProps( blockProps, {
- template: INNER_BLOCKS_TEMPLATE,
- } );
-
- 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 ] );
+ const { clientId, attributes } = props;
+
+ const [ isPatternSelectionModalOpen, setIsPatternSelectionModalOpen ] =
+ useState( false );
+ const hasInnerBlocks = useSelect(
+ ( select ) =>
+ !! select( blockEditorStore ).getBlocks( clientId ).length,
+ [ clientId ]
+ );
- /**
- * 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 (
-
+ <>
+
+ setIsPatternSelectionModalOpen( true )
+ }
+ />
+ { isPatternSelectionModalOpen && (
+
+ setIsPatternSelectionModalOpen( false )
+ }
+ />
+ ) }
+ >
);
};
diff --git a/assets/js/blocks/product-collection/edit/inspector-controls/columns-control.tsx b/assets/js/blocks/product-collection/edit/inspector-controls/columns-control.tsx
index 9c74f4a135c..781e23fd089 100644
--- a/assets/js/blocks/product-collection/edit/inspector-controls/columns-control.tsx
+++ b/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', 'woo-gutenberg-products-block' );
@@ -23,7 +23,7 @@ const toggleHelp = __(
'woo-gutenberg-products-block'
);
-const ColumnsControl = ( props: DisplayLayoutToolbarProps ) => {
+const ColumnsControl = ( props: DisplayLayoutControlProps ) => {
const { type, columns, shrinkColumns } = props.displayLayout;
const showColumnsControl = type === 'flex';
diff --git a/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx b/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx
index 2109dc03977..32ca8818bec 100644
--- a/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx
+++ b/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,9 +25,14 @@ 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 { getUnchangeableFilters } from '../../collections';
import UpgradeNotice from './upgrade-notice';
import ColumnsControl from './columns-control';
import InheritQueryControl from './inherit-query-control';
@@ -42,12 +47,28 @@ import LayoutOptionsControl from './layout-options-control';
import FeaturedProductsControl from './featured-products-control';
import CreatedControl from './created-control';
+const prepareShouldShowFilter =
+ ( unchangeableFilters: FilterName[] ) => ( filter: FilterName ) => {
+ return ! unchangeableFilters.includes( filter );
+ };
+
const ProductCollectionInspectorControls = (
props: BlockEditProps< ProductCollectionAttributes >
) => {
- const query = props.attributes.query;
+ const { query, collection } = props.attributes;
const inherit = query?.inherit;
- const displayQueryControls = inherit === false;
+ const unchangeableFilters = getUnchangeableFilters( collection );
+ const shouldShowFilter = prepareShouldShowFilter( unchangeableFilters );
+
+ // To be changed - inherit control will be hidden completely once Custom
+ // collection is introduced
+ 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 ),
@@ -77,13 +98,15 @@ const ProductCollectionInspectorControls = (
>
-
- { displayQueryControls ? (
+ { showInheritQueryControls ? (
+
+ ) : null }
+ { showOrderControl ? (
) : null }
- { displayQueryControls ? (
+ { showQueryControls ? (
void )[] ) => {
@@ -94,13 +117,17 @@ const ProductCollectionInspectorControls = (
} }
className="wc-block-editor-product-collection-inspector-toolspanel__filters"
>
-
+ { showOnSaleControl ? (
+
+ ) : null }
-
+ { showFeaturedControl ? (
+
+ ) : null }
) : null }
@@ -200,8 +227,4 @@ export const withUpgradeNoticeControls =
);
};
-addFilter(
- 'editor.BlockEdit',
- 'woocommerce/product-collection',
- withUpgradeNoticeControls
-);
+addFilter( 'editor.BlockEdit', metadata.name, withUpgradeNoticeControls );
diff --git a/assets/js/blocks/product-collection/edit/inspector-controls/layout-options-control.tsx b/assets/js/blocks/product-collection/edit/inspector-controls/layout-options-control.tsx
index 0377176e34c..12914033e84 100644
--- a/assets/js/blocks/product-collection/edit/inspector-controls/layout-options-control.tsx
+++ b/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 ) {
@@ -40,7 +40,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/assets/js/blocks/product-collection/edit/product-collection-content.tsx b/assets/js/blocks/product-collection/edit/product-collection-content.tsx
new file mode 100644
index 00000000000..8eace5c6783
--- /dev/null
+++ b/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/assets/js/blocks/product-collection/edit/product-collection-placeholder.tsx b/assets/js/blocks/product-collection/edit/product-collection-placeholder.tsx
new file mode 100644
index 00000000000..3025935a30c
--- /dev/null
+++ b/assets/js/blocks/product-collection/edit/product-collection-placeholder.tsx
@@ -0,0 +1,177 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import {
+ store as blockEditorStore,
+ useBlockProps,
+} from '@wordpress/block-editor';
+import { Placeholder, Button } from '@wordpress/components';
+import { useSelect, useDispatch } from '@wordpress/data';
+import {
+ // @ts-expect-error Type definitions for this function are missing in Guteberg
+ store as blocksStore,
+ createBlock,
+ // @ts-expect-error Type definitions for this function are missing in Guteberg
+ createBlocksFromInnerBlocksTemplate,
+} from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import type {
+ ProductCollectionEditComponentProps,
+ ProductCollectionAttributes,
+} from '../types';
+import { getDefaultProductCollection } from '../constants';
+import Icon from '../icon';
+import blockJson from '../block.json';
+import productCatalog from '../collections/product-catalog';
+
+type CollectionButtonProps = {
+ active: boolean;
+ title: string;
+ icon: string;
+ description: string;
+ onClick: () => void;
+};
+
+const CollectionButton = ( {
+ active,
+ title,
+ icon,
+ description,
+ onClick,
+}: CollectionButtonProps ) => {
+ const variant = active ? 'primary' : 'secondary';
+
+ return (
+
+ );
+};
+
+const getDefaultChosenCollection = (
+ attributes: ProductCollectionAttributes,
+ // @ts-expect-error Type definitions are missing
+ // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wordpress__blocks/store/selectors.d.ts
+ blockCollections
+) => {
+ // If `attributes.query` is truthy, that means Product Collection was already
+ // configured. So it's either a collection or we need to return defaultQuery
+ // collection name;
+ if ( attributes.query ) {
+ return attributes.collection || productCatalog.name;
+ }
+
+ // Otherwise it should be the first available choice. We control collections
+ // so there's always at least one available.
+ return blockCollections.length ? blockCollections[ 0 ].name : '';
+};
+
+const ProductCollectionPlaceholder = (
+ props: ProductCollectionEditComponentProps
+) => {
+ const blockProps = useBlockProps();
+ 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 );
+
+ // Get Collections
+ const blockCollections = [
+ productCatalog,
+ ...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 );
+ }, [] ),
+ ];
+
+ // Prepare Collections
+ const defaultChosenCollection = getDefaultChosenCollection(
+ attributes,
+ blockCollections
+ );
+
+ const applyCollection = ( chosenCollectionName: string ) => {
+ // Case 1: Merchant has chosen Default Query. In that case we create defaultProductCollection
+ if (
+ chosenCollectionName ===
+ 'woocommerce-blocks/product-collection/default-query'
+ ) {
+ const defaultProductCollection = getDefaultProductCollection();
+ replaceBlock( clientId, defaultProductCollection );
+ return;
+ }
+
+ // Case 2: Merchant has chosen another Collection
+ const chosenCollection = blockCollections.find(
+ ( { name }: { name: string } ) => name === chosenCollectionName
+ );
+
+ const newBlock = createBlock(
+ blockJson.name,
+ chosenCollection.attributes,
+ createBlocksFromInnerBlocksTemplate( chosenCollection.innerBlocks )
+ );
+
+ replaceBlock( clientId, newBlock );
+ };
+
+ return (
+
+
+
+ { __(
+ "Pick what products are shown. Don't worry, you can switch and tweak this collection any time.",
+ 'woo-gutenberg-products-block'
+ ) }
+
+
+ { blockCollections.map(
+ ( { name, title, icon, description } ) => (
+ applyCollection( name ) }
+ />
+ )
+ ) }
+
+
+
+ );
+};
+
+export default ProductCollectionPlaceholder;
diff --git a/assets/js/blocks/product-collection/edit/toolbar-controls/coolection-chooser-toolbar.tsx b/assets/js/blocks/product-collection/edit/toolbar-controls/coolection-chooser-toolbar.tsx
new file mode 100644
index 00000000000..3b808f21b03
--- /dev/null
+++ b/assets/js/blocks/product-collection/edit/toolbar-controls/coolection-chooser-toolbar.tsx
@@ -0,0 +1,19 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
+
+const CollectionChooserToolbar = ( props: {
+ openPatternSelectionModal: () => void;
+} ) => {
+ return (
+
+
+ { __( 'Choose collection', 'woo-gutenberg-products-block' ) }
+
+
+ );
+};
+
+export default CollectionChooserToolbar;
diff --git a/assets/js/blocks/product-collection/edit/toolbar-controls/display-layout-toolbar.tsx b/assets/js/blocks/product-collection/edit/toolbar-controls/display-layout-toolbar.tsx
index d2445a04ec8..e3d9c5a86ad 100644
--- a/assets/js/blocks/product-collection/edit/toolbar-controls/display-layout-toolbar.tsx
+++ b/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/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx b/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx
index 0d87fb53ec5..e6dd0dc8b1f 100644
--- a/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx
+++ b/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 PatternChooserToolbar from './coolection-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, openPatternSelectionModal, setAttributes } = props;
const { query, displayLayout } = attributes;
const setQueryAttributeBind = useMemo(
@@ -32,9 +27,7 @@ export default function ToolbarControls(
return (
- setIsPatternSelectionModalOpen( true )
- }
+ openPatternSelectionModal={ openPatternSelectionModal }
/>
{ ! query.inherit && (
<>
@@ -48,15 +41,6 @@ export default function ToolbarControls(
/>
>
) }
- { isPatternSelectionModalOpen && (
-
- setIsPatternSelectionModalOpen( false )
- }
- />
- ) }
);
}
diff --git a/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-chooser-toolbar.tsx b/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-chooser-toolbar.tsx
index 6c9a0c0139e..3b808f21b03 100644
--- a/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-chooser-toolbar.tsx
+++ b/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-chooser-toolbar.tsx
@@ -4,16 +4,16 @@
import { __ } from '@wordpress/i18n';
import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
-const DisplayLayoutControl = ( props: {
+const CollectionChooserToolbar = ( props: {
openPatternSelectionModal: () => void;
} ) => {
return (
- { __( 'Choose pattern', 'woo-gutenberg-products-block' ) }
+ { __( 'Choose collection', 'woo-gutenberg-products-block' ) }
);
};
-export default DisplayLayoutControl;
+export default CollectionChooserToolbar;
diff --git a/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-selection-modal.tsx b/assets/js/blocks/product-collection/edit/toolbar-controls/pattern-selection-modal.tsx
deleted file mode 100644
index 043af3a7a15..00000000000
--- a/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/assets/js/blocks/product-collection/index.tsx b/assets/js/blocks/product-collection/index.tsx
index 62b9df69dcd..61d7919ec53 100644
--- a/assets/js/blocks/product-collection/index.tsx
+++ b/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/assets/js/blocks/product-collection/types.ts b/assets/js/blocks/product-collection/types.ts
index bd14b773c37..8ef0e27357c 100644
--- a/assets/js/blocks/product-collection/types.ts
+++ b/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,7 @@ export interface ProductCollectionAttributes {
displayLayout: ProductCollectionDisplayLayout;
tagName: string;
convertedFromProducts: boolean;
+ collection?: string;
}
export enum LayoutOptions {
@@ -74,6 +76,11 @@ export interface ProductCollectionQuery {
woocommerceHandPickedProducts?: string[];
}
+export type ProductCollectionEditComponentProps =
+ BlockEditProps< ProductCollectionAttributes > & {
+ openPatternSelectionModal: () => void;
+ };
+
export type TProductCollectionOrder = 'asc' | 'desc';
export type TProductCollectionOrderBy =
| 'date'
@@ -81,7 +88,7 @@ export type TProductCollectionOrderBy =
| 'popularity'
| 'rating';
-export type DisplayLayoutToolbarProps = {
+export type DisplayLayoutControlProps = {
displayLayout: ProductCollectionDisplayLayout;
setAttributes: ( attrs: Partial< ProductCollectionAttributes > ) => void;
};
@@ -89,3 +96,28 @@ export type QueryControlProps = {
query: ProductCollectionQuery;
setQueryAttribute: ( attrs: Partial< ProductCollectionQuery > ) => void;
};
+
+export enum CoreCollectionNames {
+ PRODUCT_CATALOG = 'woocommerce-blocks/product-collection/product-catalog',
+ BEST_SELLERS = 'woocommerce-blocks/product-collection/best-sellers',
+ FEATURED = 'woocommerce-blocks/product-collection/featured',
+ NEW_ARRIVALS = 'woocommerce-blocks/product-collection/new-arrivals',
+ ON_SALE = 'woocommerce-blocks/product-collection/on-sale',
+ TOP_RATED = 'woocommerce-blocks/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/tests/e2e/tests/product-collection/compatibility-layer.block_theme.side_effects.spec.ts b/tests/e2e/tests/product-collection/compatibility-layer.block_theme.side_effects.spec.ts
index 2113402e218..3e0eaf8c0c3 100644
--- a/tests/e2e/tests/product-collection/compatibility-layer.block_theme.side_effects.spec.ts
+++ b/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/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts b/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts
index ea28c52dd9e..1ae2b7b080d 100644
--- a/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts
+++ b/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,136 @@ 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 );
+ } );
+
+ test( 'Top Rated Collection can be added and displays proper products', async ( {
+ pageObject,
+ } ) => {
+ await pageObject.createNewPostAndInsertBlock( 'topRated' );
+
+ const topRatedProducts = [
+ 'Beanie',
+ 'Logo Collection',
+ 'Belt',
+ 'WordPress Pennant',
+ 'Cap',
+ ];
+
+ await expect( pageObject.products ).toHaveCount( 5 );
+ await expect( pageObject.productTitles ).toHaveText(
+ topRatedProducts
+ );
+
+ await pageObject.publishAndGoToFrontend();
+
+ await expect( pageObject.products ).toHaveCount( 5 );
+ } );
+
+ test( '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( 'Default Query Collection can be added and displays proper products', async ( {
+ pageObject,
+ } ) => {
+ await pageObject.createNewPostAndInsertBlock( 'productCatalog' );
+
+ const orderBy = await pageObject.getOrderBy();
+
+ expect( orderBy ).toBe( 'title/asc' );
+
+ await expect( pageObject.products ).toHaveCount( 9 );
+
+ await pageObject.publishAndGoToFrontend();
+
+ await expect( pageObject.products ).toHaveCount( 9 );
+ } );
+ } );
} );
diff --git a/tests/e2e/tests/product-collection/product-collection.page.ts b/tests/e2e/tests/product-collection/product-collection.page.ts
index f7145afe73c..028d1102fc9 100644
--- a/tests/e2e/tests/product-collection/product-collection.page.ts
+++ b/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 may 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/tests/e2e/utils/editor/editor-utils.page.ts b/tests/e2e/utils/editor/editor-utils.page.ts
index 93c5b1a2af1..68e162da4dd 100644
--- a/tests/e2e/utils/editor/editor-utils.page.ts
+++ b/tests/e2e/utils/editor/editor-utils.page.ts
@@ -213,6 +213,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'