Expose `__experimentalRegisterProductCollection` in @woocommerce/blocks-registry Package (#48141)

* Expose registerProductCollection in @woocommerce/blocks-registry Package

This commit exposes the `registerProductCollection` function as part of the `@woocommerce/blocks-registry` package. This enhancement facilitates the registration of new product collections by 3PDs, promoting better modularity and extensibility within the WooCommerce Blocks ecosystem.

Changes include:
- Migration of `register-product-collection.tsx` to `packages/checkout/blocks-registry`.
- Export `registerProductCollection` from `@woocommerce/blocks-registry/index.ts`.
- Updated related imports and references to the new path.

This update enables 3PDs to register product collections more seamlessly, enhancing the extensibility of Product Collection block.

* Replace @woocommerce/blocks-checkout with @woocommerce/blocks-registry

* Add __experimental prefix

* Add changefile(s) from automation for the following project(s): woocommerce-blocks

* Improve registerproductcollection for 3pds

* Set isDefault value to false

* Don't export all the types

* Update changelog

* Add changefile(s) from automation for the following project(s): woocommerce-blocks

* Add plugin to test __experimentalRegisterProductCollection

* Add E2E tests

* Fix Lint errors

* Improve E2E tests for __experimentalRegisterProductCollection

- Reduced preview timeout from 2000ms to 1000ms.
- Expanded E2E tests to cover new attributes and preview functionalities.

* Refactor code to improve readability and maintainability

- Added a warning comment to indicate that `__experimentalRegisterProductCollection` is an experimental API.
- Refactored variable names and imports in `register-product-collection.tsx` and `index.tsx` for clarity.
- Simplified and reorganized type definitions and imports in `types.ts` and `utils.tsx`.
- Renamed function in `register-product-collection-tester.php` for consistency.

* E2E: Also test the Frontend

* Use alias for import statement

* Don't pass isActive to registerProductCollection

Now it's handle by registerProductCollection itself.

* Update registerproductcollection API structure

Refactored the product collection block to enhance attribute management and ensure consistency in query defaults. This change includes:
- Importing `DEFAULT_QUERY` from constants and using it to set default query attributes.
- Removing `DEFAULT_ATTRIBUTES` from specific collections and directly defining required attributes.
- Ensuring `postType` and `isProductCollectionBlock` are set to default values in the query object.
- Setting `inherit` attribute to `false` by default in all collections.

* Hide inherit control in collections

Ensure the "inherit" control is always hidden, as collections should not be able to change this attribute. This includes:
- Adding `CoreFilterNames.INHERIT` to the `hideControls` set in `register-product-collection.tsx`.
- Adjusting the `hideControls` attribute in individual collection files to remove redundant hiding of the `INHERIT` control.

* Fix: Filters not showing in inspector controls

* Set inherit to false for all collections

* Add changefile(s) from automation for the following project(s): woocommerce-blocks

* Product Collection: Add validation for `__experimentalRegisterProductCollection` arguments (#48513)

* Add validation for `__experimentalRegisterProductCollection` arguments

Introduced comprehensive validation for the `ProductCollectionConfig` object in `__experimentalRegisterProductCollection` to ensure correct data types and values, enhancing error handling and robustness.

- Added a new function `isValidProductCollectionConfig` to perform various checks on the `ProductCollectionConfig` object.
- Validates properties such as `name`, `title`, `description`, `category`, `keywords`, `icon`, `isDefault`, `innerBlocks`, `example`, `scope`, `isActive`, `attributes`, and `preview`.
- Ensures correct data types and provides detailed console error messages for invalid configurations.
- Updated `__experimentalRegisterProductCollection` to use the validation function before proceeding with the registration process.

**Impact**
- Improves stability and prevents invalid configurations from causing runtime errors.
- Provides clearer error messages for developers, aiding in quicker debugging and development.

* Fix typo

* Refactor: Replace console.error with console.warn

Updated the error logging in the isValidProductCollectionConfig function to use console.warn instead of console.error for invalid configuration properties. This address the feedback from the PR review.

- Replaced console.error with console.warn for various validation checks in isValidProductCollectionConfig.
- Removed redundant return statements after console.warn calls.
- Improved logging messages to better inform about invalid configuration properties without treating them as critical errors.
- Simplified the logic in __experimentalRegisterProductCollection by combining query and attribute properties and ensuring defaults are set properly.

* Refactor: Rename isValidProductCollectionConfig to isValidCollectionConfig

Updated the function name from isValidProductCollectionConfig to isValidCollectionConfig for better clarity and consistency. Also, renamed related variables for improved readability.

* Add validation for name property

* Add changefile(s) from automation for the following project(s): woocommerce-blocks

* Title is required for new collection

* Update comments

* Fix E2E tests

* Address PR feedback

---------

Co-authored-by: github-actions <github-actions@github.com>

* Add README file for __experimentalRegisterProductCollection

* Add screenshots in README file

* Try to fix lint issue

* Docs: add example for collection with inner blocks

Enhanced the documentation for `__experimentalRegisterProductCollection` to include an example demonstrating how to define a collection with inner blocks. This example shows how to create a custom collection with nested blocks, including a heading and product elements, providing a clear guide for developers.

New content added:
- Example 4: Collection with inner blocks
- Sample code for defining a collection with inner blocks
- Tips and links to further resources on inner blocks and core collection definitions

* Fix Lint errors

* Address PR feedback

* Reduce number of JS files on /shop page

**Problem:** There was increase in number of JS files on /shop page after exposing `registerProductCollection` function in `@woocommerce/blocks-registry` package. This package is loaded on the frontend. For example, previously 45 JS files were loaded on /shop page but now 55 JS files are loaded on /shop page.

**Solution:**
1. After a bit of debugging I found out that constant file which we are importing i.e. `plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants.ts` contain some heavy dependencies & it's not pure. Therefore, I decided to split this file into two files. I moved all the constants that are used in `registerProductCollection` function to a new file i.e. `plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants-register-product-collection.ts`. This way, we don't need to load all the constants on the frontend i.e. /shop page.
  - This reduced 4 JS files i.e. 51 JS files are loaded on /shop page.

2. After some more investigation, I found out that importing `registerBlockVariation` function is increasing number of JS files on Frontend. Therefore, I decided to use global `wp` object to call `registerBlockVariation` function. This way, we don't need to import it. This reduced last 6 files i.e. 45 JS files are loaded on /shop page.

This way, I was able to reduce number of JS files on /shop page from 55 to 45, which is same as before this PR.

* Refactor: product collection constants

- Moved constants from `constants-register-product-collection.ts` to `constants.ts`
- Deleted `constants-register-product-collection.ts`
- Updated import paths in relevant files to reflect the changes
- Moved utility functions to `utils.ts`

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Manish Menaria 2024-07-05 16:55:35 +05:30 committed by GitHub
parent d3df046f97
commit fa11141726
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1144 additions and 212 deletions

View File

@ -1,2 +1,3 @@
export * from './payment-methods';
export * from './block-components';
export * from './product-collection/register-product-collection';

View File

@ -0,0 +1,472 @@
/* eslint-disable no-console */
/**
* External dependencies
*/
import { BlockVariation } from '@wordpress/blocks';
import { addFilter } from '@wordpress/hooks';
import { EditorBlock } from '@woocommerce/types';
import type { ElementType } from '@wordpress/element';
import type { BlockEditProps, BlockAttributes } from '@wordpress/blocks';
import {
SetPreviewState,
PreviewState,
ProductCollectionAttributes,
CoreFilterNames,
} from '@woocommerce/blocks/product-collection/types';
import {
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_TEMPLATE,
PRODUCT_COLLECTION_BLOCK_NAME as BLOCK_NAME,
DEFAULT_QUERY,
} from '@woocommerce/blocks/product-collection/constants';
export interface ProductCollectionConfig extends BlockVariation {
preview?: {
setPreviewState?: SetPreviewState;
initialPreviewState?: PreviewState;
};
}
/**
* Validates the configuration object of new collection. This function checks
* whether the provided config object adheres to the required schema and conditions necessary
* for a valid collection.
*
* Each validation step may log errors or warnings to the console if the corresponding property
* does not meet the expected criteria. It will bail early and return false, if any of the
* required properties are missing or invalid.
*/
const isValidCollectionConfig = ( config: ProductCollectionConfig ) => {
// Basic checks for the top-level argument
if ( typeof config !== 'object' || config === null ) {
console.error(
'Invalid arguments: You must pass an object to __experimentalRegisterProductCollection.'
);
return false;
}
/**
* BlockVariation properties validation
*/
// name
if ( typeof config.name !== 'string' || config.name.length === 0 ) {
console.error( 'Invalid name: name must be a non-empty string.' );
return false;
} else if (
! config.name.match(
/^[a-zA-Z0-9-]+\/product-collection\/[a-zA-Z0-9-]+$/
)
) {
console.warn(
`To prevent conflicts with other collections, please use a unique name following the pattern: "<plugin-name>/product-collection/<collection-name>". Ensure "<plugin-name>" is your plugin name and "<collection-name>" is your collection name. Both should consist only of alphanumeric characters and hyphens (e.g., "my-plugin/product-collection/my-collection").`
);
}
// title
if ( typeof config.title !== 'string' || config.title.length === 0 ) {
console.error( 'Invalid title: title must be a non-empty string.' );
return false;
}
// description
if (
config.description !== undefined &&
typeof config.description !== 'string'
) {
console.warn( 'Invalid description: description must be a string.' );
}
// category
if (
config.category !== undefined &&
typeof config.category !== 'string'
) {
console.warn( 'Invalid category: category must be a string.' );
}
// keywords
if ( config.keywords !== undefined && ! Array.isArray( config.keywords ) ) {
console.warn(
'Invalid keywords: keywords must be an array of strings.'
);
}
// icon
if (
config.icon !== undefined &&
typeof config.icon !== 'string' &&
typeof config.icon !== 'object'
) {
console.warn( 'Invalid icon: icon must be a string or an object.' );
}
// example
if ( config.example !== undefined && typeof config.example !== 'object' ) {
console.warn( 'Invalid example: example must be an object.' );
}
// scope
if ( config.scope !== undefined && ! Array.isArray( config.scope ) ) {
console.warn(
'Invalid scope: scope must be an array of type WPBlockVariationScope.'
);
}
/**
* Attributes validation
*/
// attributes
if (
config.attributes !== undefined &&
typeof config.attributes !== 'object'
) {
console.warn( 'Invalid attributes: attributes must be an object.' );
}
// attributes.query
if (
config.attributes?.query !== undefined &&
typeof config.attributes.query !== 'object'
) {
console.warn( 'Invalid query: query must be an object.' );
}
// attributes.query.offset
if (
config.attributes?.query?.offset !== undefined &&
typeof config.attributes.query.offset !== 'number'
) {
console.warn( 'Invalid offset: offset must be a number.' );
}
// attributes.query.order
if (
config.attributes?.query?.order !== undefined &&
typeof config.attributes.query.order !== 'string'
) {
console.warn( 'Invalid order: order must be a string.' );
}
// attributes.query.orderBy
if (
config.attributes?.query?.orderBy !== undefined &&
typeof config.attributes.query.orderBy !== 'string'
) {
console.warn( 'Invalid orderBy: orderBy must be a string.' );
}
// attributes.query.pages
if (
config.attributes?.query?.pages !== undefined &&
typeof config.attributes.query.pages !== 'number'
) {
console.warn( 'Invalid pages: pages must be a number.' );
}
// attributes.query.perPage
if (
config.attributes?.query?.perPage !== undefined &&
typeof config.attributes.query.perPage !== 'number'
) {
console.warn( 'Invalid perPage: perPage must be a number.' );
}
// attributes.query.search
if (
config.attributes?.query?.search !== undefined &&
typeof config.attributes.query.search !== 'string'
) {
console.warn( 'Invalid search: search must be a string.' );
}
// attributes.query.taxQuery
if (
config.attributes?.query?.taxQuery !== undefined &&
typeof config.attributes.query.taxQuery !== 'object'
) {
console.warn( 'Invalid taxQuery: taxQuery must be an object.' );
}
// attributes.query.featured
if (
config.attributes?.query?.featured !== undefined &&
typeof config.attributes.query.featured !== 'boolean'
) {
console.warn( 'Invalid featured: featured must be a boolean.' );
}
// attributes.query.timeFrame
if (
config.attributes?.query?.timeFrame !== undefined &&
typeof config.attributes.query.timeFrame !== 'object'
) {
console.warn( 'Invalid timeFrame: timeFrame must be an object.' );
}
// attributes.query.woocommerceOnSale
if (
config.attributes?.query?.woocommerceOnSale !== undefined &&
typeof config.attributes.query.woocommerceOnSale !== 'boolean'
) {
console.warn(
'Invalid woocommerceOnSale: woocommerceOnSale must be a boolean.'
);
}
// attributes.query.woocommerceStockStatus
if (
config.attributes?.query?.woocommerceStockStatus !== undefined &&
! Array.isArray( config.attributes.query.woocommerceStockStatus )
) {
console.warn(
'Invalid woocommerceStockStatus: woocommerceStockStatus must be an array.'
);
}
// attributes.query.woocommerceAttributes
if (
config.attributes?.query?.woocommerceAttributes !== undefined &&
! Array.isArray( config.attributes.query.woocommerceAttributes )
) {
console.warn(
'Invalid woocommerceAttributes: woocommerceAttributes must be an array.'
);
}
// attributes.query.woocommerceHandPickedProducts
if (
config.attributes?.query?.woocommerceHandPickedProducts !== undefined &&
! Array.isArray( config.attributes.query.woocommerceHandPickedProducts )
) {
console.warn(
'Invalid woocommerceHandPickedProducts: woocommerceHandPickedProducts must be an array.'
);
}
// attributes.query.priceRange
if (
config.attributes?.query?.priceRange !== undefined &&
typeof config.attributes.query.priceRange !== 'object'
) {
console.warn( 'Invalid priceRange: priceRange must be an object.' );
}
// attributes.displayLayout
if (
config.attributes?.displayLayout !== undefined &&
typeof config.attributes.displayLayout !== 'object'
) {
console.warn(
'Invalid displayLayout: displayLayout must be an object.'
);
}
// attributes.hideControls
if (
config.attributes?.hideControls !== undefined &&
! Array.isArray( config.attributes.hideControls )
) {
console.warn(
'Invalid hideControls: hideControls must be an array of strings.'
);
}
// attributes.queryContextIncludes
if (
config.attributes?.queryContextIncludes !== undefined &&
! Array.isArray( config.attributes.queryContextIncludes )
) {
console.warn(
'Invalid queryContextIncludes: queryContextIncludes must be an array of strings.'
);
}
/**
* Preview validation
*/
if ( config.preview !== undefined ) {
// preview
if ( typeof config.preview !== 'object' || config.preview === null ) {
console.warn( 'Invalid preview: preview must be an object.' );
}
// preview.setPreviewState
if (
config.preview.setPreviewState !== undefined &&
typeof config.preview.setPreviewState !== 'function'
) {
console.warn(
'Invalid preview: setPreviewState must be a function.'
);
}
if ( config.preview.initialPreviewState !== undefined ) {
// preview.initialPreviewState
if ( typeof config.preview.initialPreviewState !== 'object' ) {
console.warn(
'Invalid preview: initialPreviewState must be an object.'
);
}
// preview.initialPreviewState.isPreview
if (
typeof config.preview.initialPreviewState.isPreview !==
'boolean'
) {
console.warn(
'Invalid preview: preview.isPreview must be a boolean.'
);
}
// preview.initialPreviewState.previewMessage
if (
typeof config.preview.initialPreviewState.previewMessage !==
'string'
) {
console.warn(
'Invalid preview: preview.previewMessage must be a string.'
);
}
}
}
return true;
};
/**
* Register a new collection for the Product Collection block.
*
* 🚨🚨🚨 WARNING: This is an experimental API and is subject to change without notice.
*
* @param {ProductCollectionConfig} config The configuration of new collection.
*/
export const __experimentalRegisterProductCollection = (
config: ProductCollectionConfig
) => {
// If the config is invalid, return early.
if ( ! isValidCollectionConfig( config ) ) {
console.error(
'Collection could not be registered due to invalid configuration.'
);
return;
}
const { preview: { setPreviewState, initialPreviewState } = {} } = config;
const isActive = (
blockAttrs: BlockAttributes,
variationAttributes: BlockAttributes
) => {
return blockAttrs.collection === variationAttributes.collection;
};
const query = config.attributes?.query || {};
/**
* As we don't allow collections to change "inherit" attribute,
* We always need to hide the inherit control.
*/
const hideControls = [
...new Set( [
CoreFilterNames.INHERIT,
...( config.attributes?.hideControls || [] ),
] ),
];
const collectionConfigWithoutExtraArgs = {
name: config.name,
title: config.title,
description: config.description,
category: config.category,
keywords: config.keywords,
icon: config.icon,
example: config.example,
scope: config.scope,
attributes: {
query: {
...DEFAULT_QUERY,
...( query.offset !== undefined && { offset: query.offset } ),
...( query.order !== undefined && { order: query.order } ),
...( query.orderBy !== undefined && {
orderBy: query.orderBy,
} ),
...( query.pages !== undefined && { pages: query.pages } ),
...( query.perPage !== undefined && {
perPage: query.perPage,
} ),
...( query.search !== undefined && { search: query.search } ),
...( query.taxQuery !== undefined && {
taxQuery: query.taxQuery,
} ),
...( query.featured !== undefined && {
featured: query.featured,
} ),
...( query.timeFrame !== undefined && {
timeFrame: query.timeFrame,
} ),
...( query.woocommerceOnSale !== undefined && {
woocommerceOnSale: query.woocommerceOnSale,
} ),
...( query.woocommerceStockStatus !== undefined && {
woocommerceStockStatus: query.woocommerceStockStatus,
} ),
...( query.woocommerceAttributes !== undefined && {
woocommerceAttributes: query.woocommerceAttributes,
} ),
...( query.woocommerceHandPickedProducts !== undefined && {
woocommerceHandPickedProducts:
query.woocommerceHandPickedProducts,
} ),
...( query.priceRange !== undefined && {
priceRange: query.priceRange,
} ),
},
displayLayout: config.attributes?.displayLayout,
hideControls,
queryContextIncludes: config.attributes?.queryContextIncludes,
// collection should be set to the name of the collection i.e. config.name
collection: config.name,
// Collections should always have inherit set to false.
inherit: false,
},
/**
* We always want following properties to be set to the default values.
*/
innerBlocks: config.innerBlocks || INNER_BLOCKS_TEMPLATE,
isActive,
isDefault: false,
} as BlockVariation;
/**
* If setPreviewState or initialPreviewState is provided, inject the setPreviewState & initialPreviewState props.
* This is useful for handling preview mode in the editor.
*/
if ( setPreviewState || initialPreviewState ) {
const withSetPreviewState =
< T extends EditorBlock< T > >( BlockEdit: ElementType ) =>
( props: BlockEditProps< ProductCollectionAttributes > ) => {
// If collection name does not match, return the original BlockEdit component.
if (
props.attributes.collection !==
collectionConfigWithoutExtraArgs.name
) {
return <BlockEdit { ...props } />;
}
// Otherwise, inject the setPreviewState & initialPreviewState props.
return (
<BlockEdit
{ ...props }
preview={ {
setPreviewState,
initialPreviewState,
} }
/>
);
};
addFilter(
'editor.BlockEdit',
collectionConfigWithoutExtraArgs.name,
withSetPreviewState
);
}
/**
* Temporarily utilizing `wp.blocks.registerBlockVariation` directly instead of importing
* from `@wordpress/blocks` to mitigate the increase in the number of JavaScript files
* loaded on the frontend, specifically on the /shop page.
*
* TODO - Future Improvement:
* It is recommended to encapsulate the `registerProductCollection` function within a new
* package that is exclusively loaded in the editor. This strategy will eliminate
* the need to directly use `wp.blocks.registerBlockVariation`.
*/
if ( wp?.blocks?.registerBlockVariation ) {
wp.blocks.registerBlockVariation( BLOCK_NAME, {
...collectionConfigWithoutExtraArgs,
attributes: {
...DEFAULT_ATTRIBUTES,
...collectionConfigWithoutExtraArgs.attributes,
query: {
...DEFAULT_QUERY,
...collectionConfigWithoutExtraArgs.attributes?.query,
},
displayLayout: {
...DEFAULT_ATTRIBUTES.displayLayout,
...collectionConfigWithoutExtraArgs.attributes
?.displayLayout,
},
},
} );
}
};

View File

@ -8,10 +8,7 @@ import { Icon, chartBar } from '@wordpress/icons';
/**
* Internal dependencies
*/
import {
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_PRODUCT_TEMPLATE,
} from '../constants';
import { INNER_BLOCKS_PRODUCT_TEMPLATE } from '../constants';
import { CoreCollectionNames, CoreFilterNames } from '../types';
const collection = {
@ -24,22 +21,18 @@ const collection = {
};
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 ],
hideControls: [ CoreFilterNames.ORDER ],
};
const heading: InnerBlockTemplate = [

View File

@ -8,10 +8,7 @@ import { Icon, starFilled } from '@wordpress/icons';
/**
* Internal dependencies
*/
import {
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_PRODUCT_TEMPLATE,
} from '../constants';
import { INNER_BLOCKS_PRODUCT_TEMPLATE } from '../constants';
import { CoreCollectionNames, CoreFilterNames } from '../types';
const collection = {
@ -24,21 +21,17 @@ const collection = {
};
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 ],
hideControls: [ CoreFilterNames.FEATURED ],
};
const heading: InnerBlockTemplate = [

View File

@ -2,11 +2,11 @@
* External dependencies
*/
import { select } from '@wordpress/data';
import { __experimentalRegisterProductCollection as registerProductCollection } from '@woocommerce/blocks-registry';
import {
// @ts-expect-error Type definition is missing
store as blocksStore,
type BlockVariation,
BlockAttributes,
} from '@wordpress/blocks';
/**
@ -20,7 +20,6 @@ import topRated from './top-rated';
import bestSellers from './best-sellers';
import onSale from './on-sale';
import featured from './featured';
import registerProductCollection from './register-product-collection';
const collections: BlockVariation[] = [
productCollection,
@ -32,19 +31,9 @@ const collections: BlockVariation[] = [
];
export const registerCollections = () => {
collections.forEach( ( collection ) => {
const isActive = (
blockAttrs: BlockAttributes,
variationAttributes: BlockAttributes
) => {
return blockAttrs.collection === variationAttributes.collection;
};
registerProductCollection( {
isActive,
...collection,
} );
} );
collections.forEach( ( collection ) =>
registerProductCollection( collection )
);
};
export const getCollectionByName = ( collectionName?: CollectionName ) => {

View File

@ -8,10 +8,7 @@ import { Icon, calendar } from '@wordpress/icons';
/**
* Internal dependencies
*/
import {
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_PRODUCT_TEMPLATE,
} from '../constants';
import { INNER_BLOCKS_PRODUCT_TEMPLATE } from '../constants';
import {
CoreCollectionNames,
CoreFilterNames,
@ -28,15 +25,12 @@ const collection = {
};
const attributes = {
...DEFAULT_ATTRIBUTES,
displayLayout: {
type: 'flex',
columns: 5,
shrinkColumns: true,
},
query: {
...DEFAULT_ATTRIBUTES.query,
inherit: false,
orderBy: 'date',
order: 'desc',
perPage: 5,
@ -46,8 +40,7 @@ const attributes = {
value: '-7 days',
},
},
collection: collection.name,
hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.ORDER ],
hideControls: [ CoreFilterNames.ORDER ],
};
const heading: InnerBlockTemplate = [

View File

@ -8,10 +8,7 @@ import { Icon, percent } from '@wordpress/icons';
/**
* Internal dependencies
*/
import {
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_PRODUCT_TEMPLATE,
} from '../constants';
import { INNER_BLOCKS_PRODUCT_TEMPLATE } from '../constants';
import { CoreCollectionNames, CoreFilterNames } from '../types';
const collection = {
@ -27,21 +24,17 @@ const collection = {
};
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 ],
hideControls: [ CoreFilterNames.ON_SALE ],
};
const heading: InnerBlockTemplate = [

View File

@ -8,7 +8,7 @@ import { Icon, loop } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants';
import { INNER_BLOCKS_TEMPLATE } from '../constants';
import { CoreCollectionNames } from '../types';
const collection = {
@ -21,19 +21,9 @@ const collection = {
scope: [],
};
const attributes = {
...DEFAULT_ATTRIBUTES,
query: {
...DEFAULT_ATTRIBUTES.query,
},
collection: collection.name,
hideControls: [],
};
const innerBlocks: InnerBlockTemplate[] = INNER_BLOCKS_TEMPLATE;
export default {
...collection,
attributes,
innerBlocks,
};

View File

@ -1,72 +0,0 @@
/**
* External dependencies
*/
import { BlockVariation, registerBlockVariation } from '@wordpress/blocks';
import { addFilter } from '@wordpress/hooks';
import { EditorBlock } from '@woocommerce/types';
import type { ElementType } from '@wordpress/element';
import type { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import blockJson from '../block.json';
import {
SetPreviewState,
PreviewState,
ProductCollectionAttributes,
} from '../types';
export interface ProductCollectionConfig extends BlockVariation {
preview?: {
setPreviewState?: SetPreviewState;
initialPreviewState?: PreviewState;
};
}
/**
* Register a new collection for the Product Collection block.
*
* @param {ProductCollectionConfig} blockVariationArgs The configuration of new collection.
*/
const registerProductCollection = ( {
preview: { setPreviewState, initialPreviewState } = {},
...blockVariationArgs
}: ProductCollectionConfig ) => {
/**
* If setPreviewState or initialPreviewState is provided, inject the setPreviewState & initialPreviewState props.
* This is useful for handling preview mode in the editor.
*/
if ( setPreviewState || initialPreviewState ) {
const withSetPreviewState =
< T extends EditorBlock< T > >( BlockEdit: ElementType ) =>
( props: BlockEditProps< ProductCollectionAttributes > ) => {
// If collection name does not match, return the original BlockEdit component.
if ( props.attributes.collection !== blockVariationArgs.name ) {
return <BlockEdit { ...props } />;
}
// Otherwise, inject the setPreviewState & initialPreviewState props.
return (
<BlockEdit
{ ...props }
preview={ {
setPreviewState,
initialPreviewState,
} }
/>
);
};
addFilter(
'editor.BlockEdit',
blockVariationArgs.name,
withSetPreviewState
);
}
registerBlockVariation( blockJson.name, {
...blockVariationArgs,
} );
};
export default registerProductCollection;

View File

@ -8,10 +8,7 @@ import { Icon, starEmpty } from '@wordpress/icons';
/**
* Internal dependencies
*/
import {
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_PRODUCT_TEMPLATE,
} from '../constants';
import { INNER_BLOCKS_PRODUCT_TEMPLATE } from '../constants';
import { CoreCollectionNames, CoreFilterNames } from '../types';
const collection = {
@ -27,22 +24,18 @@ const collection = {
};
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 ],
hideControls: [ CoreFilterNames.ORDER ],
};
const heading: InnerBlockTemplate = [

View File

@ -1,30 +1,31 @@
/**
* Purpose of this file:
* This file defines constants for use in `plugins/woocommerce-blocks/assets/js/blocks-registry/product-collection/register-product-collection.tsx`.
* By isolating constants here, we avoid loading unnecessary JS file on the frontend (e.g., the /shop page), enhancing site performance.
*
* Context: https://github.com/woocommerce/woocommerce/pull/48141#issuecomment-2208770592.
*/
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
import { objectOmit } from '@woocommerce/utils';
import {
type InnerBlockTemplate,
createBlock,
// @ts-expect-error Type definitions for this function are missing in Guteberg
createBlocksFromInnerBlocksTemplate,
} from '@wordpress/blocks';
import type { InnerBlockTemplate } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import blockJson from './block.json';
import {
ProductCollectionAttributes,
TProductCollectionOrder,
TProductCollectionOrderBy,
ProductCollectionQuery,
ProductCollectionDisplayLayout,
LayoutOptions,
} from './types';
import { ImageSizing } from '../../atomic/blocks/product-elements/image/types';
import { VARIATION_NAME as PRODUCT_TITLE_ID } from './variations/elements/product-title';
import { getDefaultValueOfInheritQueryFromTemplate } from './utils';
import blockJson from './block.json';
export const PRODUCT_COLLECTION_BLOCK_NAME = blockJson.name;
const PRODUCT_TITLE_NAME = `${ PRODUCT_COLLECTION_BLOCK_NAME }/product-title`;
export const STOCK_STATUS_OPTIONS = getSetting< Record< string, string > >(
'stockStatusOptions',
@ -51,7 +52,7 @@ export const DEFAULT_QUERY: ProductCollectionQuery = {
orderBy: 'title',
search: '',
exclude: [],
inherit: null,
inherit: false,
taxQuery: {},
isProductCollectionBlock: true,
featured: false,
@ -82,25 +83,6 @@ export const DEFAULT_ATTRIBUTES: Pick<
forcePageReload: false,
};
export const getDefaultQuery = (
currentQuery: ProductCollectionQuery
): ProductCollectionQuery => ( {
...currentQuery,
orderBy: DEFAULT_QUERY.orderBy as TProductCollectionOrderBy,
order: DEFAULT_QUERY.order as TProductCollectionOrder,
inherit: getDefaultValueOfInheritQueryFromTemplate(),
} );
export const getDefaultDisplayLayout = () =>
DEFAULT_ATTRIBUTES.displayLayout as ProductCollectionDisplayLayout;
export const getDefaultSettings = (
currentAttributes: ProductCollectionAttributes
): Partial< ProductCollectionAttributes > => ( {
displayLayout: getDefaultDisplayLayout(),
query: getDefaultQuery( currentAttributes.query ),
} );
export const DEFAULT_FILTERS: Pick<
ProductCollectionQuery,
| 'woocommerceOnSale'
@ -151,7 +133,7 @@ export const INNER_BLOCKS_PRODUCT_TEMPLATE: InnerBlockTemplate = [
},
},
isLink: true,
__woocommerceNamespace: PRODUCT_TITLE_ID,
__woocommerceNamespace: PRODUCT_TITLE_NAME,
},
],
[
@ -191,16 +173,3 @@ export const INNER_BLOCKS_TEMPLATE: InnerBlockTemplate[] = [
INNER_BLOCKS_PAGINATION_TEMPLATE,
INNER_BLOCKS_NO_RESULTS_TEMPLATE,
];
export const getDefaultProductCollection = () =>
createBlock(
blockJson.name,
{
...DEFAULT_ATTRIBUTES,
query: {
...DEFAULT_ATTRIBUTES.query,
inherit: getDefaultValueOfInheritQueryFromTemplate(),
},
},
createBlocksFromInnerBlocksTemplate( INNER_BLOCKS_TEMPLATE )
);

View File

@ -18,7 +18,7 @@ import {
import { type CollectionName, CoreCollectionNames } from '../types';
import blockJson from '../block.json';
import { getCollectionByName } from '../collections';
import { getDefaultProductCollection } from '../constants';
import { getDefaultProductCollection } from '../utils';
type CollectionButtonProps = {
active?: boolean;

View File

@ -14,7 +14,7 @@ import {
* Internal dependencies
*/
import { DisplayLayoutControlProps } from '../../types';
import { getDefaultDisplayLayout } from '../../constants';
import { getDefaultDisplayLayout } from '../../utils';
const columnsLabel = __( 'Columns', 'woocommerce' );
const toggleLabel = __( 'Responsive', 'woocommerce' );

View File

@ -33,8 +33,7 @@ import {
CoreFilterNames,
FilterName,
} from '../../types';
import { setQueryAttribute } from '../../utils';
import { getDefaultSettings } from '../../constants';
import { setQueryAttribute, getDefaultSettings } from '../../utils';
import UpgradeNotice from './upgrade-notice';
import ColumnsControl from './columns-control';
import InheritQueryControl from './inherit-query-control';
@ -69,13 +68,15 @@ const ProductCollectionInspectorControls = (
filter,
} );
const inherit = query?.inherit || false;
const shouldShowFilter = prepareShouldShowFilter( hideControls );
const isArchiveTemplate =
tracksLocation === 'product-catalog' ||
tracksLocation === 'product-archive';
const showQueryControls = query?.inherit === false;
const showQueryControls = inherit === false;
const showInheritQueryControl =
isArchiveTemplate && shouldShowFilter( CoreFilterNames.INHERIT );
const showOrderControl =

View File

@ -18,7 +18,7 @@ import {
QueryControlProps,
CoreFilterNames,
} from '../../types';
import { getDefaultQuery } from '../../constants';
import { getDefaultQuery } from '../../utils';
const orderOptions = [
{

View File

@ -120,15 +120,6 @@ const ProductCollectionContent = ( {
[]
);
/**
* 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;
}
/**
* If default attributes are not set, we don't wanna render anything.
* Default attributes are set in the useEffect above.
@ -152,7 +143,7 @@ const ProductCollectionContent = ( {
attributes.__privatePreviewState?.previewMessage
}
className="wc-block-product-collection__preview-button"
data-test-id="product-collection-preview-button"
data-testid="product-collection-preview-button"
>
Preview
</Button>

View File

@ -60,7 +60,7 @@ export interface PriceRange {
export interface ProductCollectionQuery {
exclude: string[];
inherit: boolean | null;
inherit: boolean;
offset: number;
order: TProductCollectionOrder;
orderBy: TProductCollectionOrderBy;

View File

@ -8,17 +8,30 @@ import { isWpVersion } from '@woocommerce/settings';
import type { BlockEditProps, Block } from '@wordpress/blocks';
import { useLayoutEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
createBlock,
// @ts-expect-error Type definitions for this function are missing in Guteberg
createBlocksFromInnerBlocksTemplate,
} from '@wordpress/blocks';
/**
* Internal dependencies
*/
import {
PreviewState,
ProductCollectionAttributes,
TProductCollectionOrder,
TProductCollectionOrderBy,
ProductCollectionQuery,
ProductCollectionDisplayLayout,
PreviewState,
SetPreviewState,
} from './types';
import { coreQueryPaginationBlockName } from './constants';
import {
coreQueryPaginationBlockName,
DEFAULT_QUERY,
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_TEMPLATE,
} from './constants';
import blockJson from './block.json';
import {
LocationType,
@ -206,3 +219,35 @@ export const useSetPreviewState = ( {
setPreviewState,
] );
};
export const getDefaultQuery = (
currentQuery: ProductCollectionQuery
): ProductCollectionQuery => ( {
...currentQuery,
orderBy: DEFAULT_QUERY.orderBy as TProductCollectionOrderBy,
order: DEFAULT_QUERY.order as TProductCollectionOrder,
inherit: getDefaultValueOfInheritQueryFromTemplate(),
} );
export const getDefaultDisplayLayout = () =>
DEFAULT_ATTRIBUTES.displayLayout as ProductCollectionDisplayLayout;
export const getDefaultSettings = (
currentAttributes: ProductCollectionAttributes
): Partial< ProductCollectionAttributes > => ( {
displayLayout: getDefaultDisplayLayout(),
query: getDefaultQuery( currentAttributes.query ),
} );
export const getDefaultProductCollection = () =>
createBlock(
blockJson.name,
{
...DEFAULT_ATTRIBUTES,
query: {
...DEFAULT_ATTRIBUTES.query,
inherit: getDefaultValueOfInheritQueryFromTemplate(),
},
},
createBlocksFromInnerBlocksTemplate( INNER_BLOCKS_TEMPLATE )
);

View File

@ -0,0 +1,232 @@
# Register Product Collection
The `__experimentalRegisterProductCollection` function is part of the `@woocommerce/blocks-registry` package. This function allows 3PDs to register a new collection. This function accepts most of the arguments that are accepted by [Block Variation](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-variations/#defining-a-block-variation).
> [!WARNING]
> It's experimental and may change in the future. Please use it with caution.
**There are two ways to use this function:**
1. Using `@woocommerce/dependency-extraction-webpack-plugin` in a Webpack configuration: This will allow you to import the function from the package & use it in your code. For example:
```tsx
import { __experimentalRegisterProductCollection } from "@woocommerce/blocks-registry";
```
2. Using the global `wc` object: This will allow you to use the function using the global JS object without importing it. For example:
```tsx
wc.wcBlocksRegistry.__experimentalRegisterProductCollection({
...
});
```
> [!TIP]
> The first method is recommended if you are using Webpack.
## Defining a Collection
We will explain important arguments that can be passed to `__experimentalRegisterProductCollection`. For other arguments, you can refer to the [Block Variation](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-variations/#defining-a-block-variation) documentation.
A Collection is defined by an object that can contain the following fields:
- `name` (type `string`): A unique and machine-readable collection name. We recommend using the format `<plugin-name>/product-collection/<collection-name>`. Both `<plugin-name>` and `<collection-name>` should consist only of alphanumeric characters and hyphens (e.g., `my-plugin/product-collection/my-collection`).
- `title` (type `string`): The title of the collection, which will be displayed in various places including the block inserter and collection chooser.
- `description` (optional, type `string`): A human-readable description of the collection.
- `innerBlocks` (optional, type `Array[]`): An array of inner blocks that will be added to the collection. If not provided, the default inner blocks will be used.
- `isDefault`: ⚠️ It's set to `false` for all collections. 3PDs doesn't need to pass this argument.
- `isActive`: ⚠️ It will be managed by us. 3PDs doesn't need to pass this argument.
### Attributes
Attributes are the properties that define the behavior of the collection. All the attributes are *optional*. Here are some of the important attributes that can be passed to `__experimentalRegisterProductCollection`:
- `query` (type `object`): The query object that defines the query for the collection. It can contain the following fields:
- `offset` (type `number`): The number of items to offset the query by.
- `order` (type `string`): The order of the query. Accepted values are `asc` and `desc`.
- `orderBy` (type `string`): The field to order the query by.
- `pages` (type `number`): The number of pages to query.
- `perPage` (type `number`): The number of products per page.
- `search` (type `string`): The search term to query by.
- `taxQuery` (type `object`): The tax query to filter the query by. For example, if you wanna fetch products with category `Clothing` & `Accessories` and tag `Summer` then you can pass `taxQuery` as `{"product_cat":[20,17],"product_tag":[36]}`. Where array values are the term IDs.
- `featured` (type `boolean`): Whether to query for featured items.
- `timeFrame` (type `object`): The time frame to query by.
- `operator` (type `string`): The operator to use for the time frame query. Accepted values are `in` and `not-in`.
- `value` (type `string`): The value to query by. It should be a valid date string that PHP's `strtotime` function can parse.
- `woocommerceOnSale` (type `boolean`): Whether to query for items on sale.
- `woocommerceStockStatus` (type `array`): The stock status to query by. Some of the accepted values are `instock`, `outofstock`, `onbackorder`.
- `woocommerceAttributes` (type `array`): The attributes to query by.
- For example, if you wanna fetch products with color `blue` & `gray` and size `Large` then you can pass `woocommerceAttributes` as `[{"termId":23,"taxonomy":"pa_color"},{"termId":26,"taxonomy":"pa_size"},{"termId":29,"taxonomy":"pa_color"}]`.
- `woocommerceHandPickedProducts` (type `array`): The hand-picked products to query by. It should contain the product IDs.
- `priceRange` (type `object`): The price range to query by.
- `min` (type `number`): The minimum price.
- `max` (type `number`): The maximum price.
- `displayLayout` (type `object`): The display layout object that defines the layout of the collection. It can contain the following fields:
- `type` (type `string`): The type of layout. Accepted values are `grid` and `stack`.
- `columns` (type `number`): The number of columns to display.
- `shrinkColumns` (type `boolean`): Whether the layout should be responsive.
- `hideControls` (type `array`): The controls to hide.
#### Preview Attribute
The `preview` attribute is optional, and it is used to set the preview state of the collection. It can contain the following fields:
- `initialPreviewState` (type `object`): The initial preview state of the collection. It can contain the following fields:
- `isPreview` (type `boolean`): Whether the collection is in preview mode.
- `previewMessage` (type `string`): The message to be displayed in the Tooltip when the user hovers over the preview label.
- `setPreviewState` (optional, type `function`): The function to set the preview state of the collection. It receives the following arguments:
- `setState` (type `function`): The function to set the preview state. You can pass a new preview state to this function containing `isPreview` and `previewMessage`.
- `attributes` (type `object`): The current attributes of the collection.
- `location` (type `object`): The location of the collection. Accepted values are `product`, `archive`, `cart`, `order`, `site`.
For more info, you may check PR #46369, in which the Preview feature was added
## Examples
### Example 1: Registering a new collection
```tsx
__experimentalRegisterProductCollection({
name: "your-plugin-name/product-collection/my-custom-collection",
title: "My Custom Collection",
icon: "games",
description: "This is a custom collection.",
keywords: ["custom collection", "product collection"],
});
```
As you can see in the example above, we are registering a new collection with the name `woocommerce/product-collection/my-custom-collection` & title `My Custom Collection`. Here is screenshot of how it will look like:
![image](https://github.com/woocommerce/woocommerce/assets/16707866/7fddbc02-a6cd-494e-b2f4-13dd5ef9cf96)
### Example 2: Register a new collection with a preview
As you can see below, setting the initial preview state is possible. In the example below, we are setting `isPreview` and `previewMessage`.
```tsx
__experimentalRegisterProductCollection({
name: "your-plugin-name/product-collection/my-custom-collection-with-preview",
title: "My Custom Collection with Preview",
icon: "games",
description: "This is a custom collection with preview.",
keywords: ["My Custom Collection with Preview", "product collection"],
preview: {
initialPreviewState: {
isPreview: true,
previewMessage:
"This is a preview message for my custom collection with preview.",
},
},
attributes: {
query: {
perPage: 5,
featured: true,
},
displayLayout: {
type: "grid",
columns: 3,
shrinkColumns: true,
},
},
});
```
Here is how it will look like:
![image](https://github.com/woocommerce/woocommerce/assets/16707866/5fc1aa20-552a-4e09-b811-08babab46665)
### Example 3: Advanced usage of preview
As you can see below, it's also possible to use `setPreviewState` to set the preview state. In the example below, we are setting `initialPreviewState` and using `setPreviewState` to change the preview state after 5 seconds.
**This example shows:**
- How to access current attributes and location in the preview state
- How to use async operations
- We are using `setTimeout` to change the preview state after 5 seconds. You can use any async operation, like fetching data from an API, to decide the preview state.
- How to use the cleanup function as a return value
- We are returning a cleanup function that will clear the timeout after the component is unmounted. You can use this to clean up any resources you have used in `setPreviewState`.
```tsx
__experimentalRegisterProductCollection({
name: "your-plugin-name/product-collection/my-custom-collection-with-advanced-preview",
title: "My Custom Collection with Advanced Preview",
icon: "games",
description: "This is a custom collection with advanced preview.",
keywords: [
"My Custom Collection with Advanced Preview",
"product collection",
],
preview: {
setPreviewState: ({
setState,
attributes: currentAttributes,
location,
}) => {
// setPreviewState has access to the current attributes and location.
// console.log( currentAttributes, location );
const timeoutID = setTimeout(() => {
setState({
isPreview: false,
previewMessage: "",
});
}, 5000);
return () => clearTimeout(timeoutID);
},
initialPreviewState: {
isPreview: true,
previewMessage:
"This is a preview message for my custom collection with advanced preview.",
},
},
});
```
### Example 4: Collection with inner blocks
As you can see below, it's also possible to define inner blocks for the collection. In the example below, we are defining inner blocks for the collection.
```tsx
__experimentalRegisterProductCollection({
name: "your-plugin-name/product-collection/my-custom-collection-with-inner-blocks",
title: "My Custom Collection with Inner Blocks",
icon: "games",
description: "This is a custom collection with inner blocks.",
keywords: ["My Custom Collection with Inner Blocks", "product collection"],
innerBlocks: [
[
"core/heading",
{
textAlign: "center",
level: 2,
content: "Title of the collection",
},
],
[
"woocommerce/product-template",
{},
[
["woocommerce/product-image"],
[
"woocommerce/product-price",
{
textAlign: "center",
fontSize: "small",
},
],
],
],
],
});
```
This will create a collection with a heading, product image, and product price. Here is how it will look like:
![image](https://github.com/woocommerce/woocommerce/assets/16707866/3d92c084-91e9-4872-a898-080b4b93afca)
> ![TIP]
> You can learn more about inner blocks template in the [Inner Blocks](https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/nested-blocks-inner-blocks/#template) documentation.
> ![TIP]
> You can also take a look at how we are defining our core collections at `plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections` directory. Our core collections will also evolve over time.

View File

@ -0,0 +1,30 @@
<?php
/**
* Plugin Name: Register Product Collection Tester
* Description: A plugin to test the registerProductCollection function from WooCommerce Blocks.
* Plugin URI: https://github.com/woocommerce/woocommerce
* Author: WooCommerce
*
*
* @package register-product-collection-tester
*/
// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit;
}
// Enqueue the JavaScript file.
function register_product_collections_script()
{
wp_enqueue_script(
'rpc_register_product_collections',
plugins_url('register-product-collection-tester/index.js', __FILE__),
array('wp-element', 'wp-blocks', 'wp-i18n', 'wp-components', 'wp-editor', 'wc-blocks'),
filemtime(plugin_dir_path(__FILE__) . 'register-product-collection-tester/index.js'),
true
);
}
add_action('enqueue_block_editor_assets', 'register_product_collections_script');

View File

@ -0,0 +1,63 @@
/* eslint-disable @typescript-eslint/no-unused-vars, no-undef */
const { __experimentalRegisterProductCollection } = wc.wcBlocksRegistry;
// Example 1: Register a new collection.
__experimentalRegisterProductCollection( {
name: 'woocommerce/product-collection/my-custom-collection',
title: 'My Custom Collection',
description: 'This is a custom collection.',
keywords: [ 'custom collection', 'product collection' ],
attributes: {
query: {
perPage: 5,
},
},
} );
// Example 2: Register a new collection with a preview.
__experimentalRegisterProductCollection( {
name: 'woocommerce/product-collection/my-custom-collection-with-preview',
title: 'My Custom Collection with Preview',
description: 'This is a custom collection with preview.',
keywords: [ 'custom collection', 'product collection' ],
preview: {
initialPreviewState: {
isPreview: true,
previewMessage:
'This is a preview message for my custom collection with preview.',
},
},
} );
// Example 3: Advanced usage of preview.
__experimentalRegisterProductCollection( {
name: 'woocommerce/product-collection/my-custom-collection-with-advanced-preview',
title: 'My Custom Collection with Advanced Preview',
description: 'This is a custom collection with advanced preview.',
keywords: [ 'custom collection', 'product collection' ],
preview: {
setPreviewState: ( {
setState,
attributes: currentAttributes,
location,
} ) => {
// You can access the current attributes and location.
// console.log( 'Current attributes:', currentAttributes );
// console.log( 'Location:', location );
const timeoutID = setTimeout( () => {
setState( {
isPreview: false,
previewMessage: '',
} );
}, 1000 );
return () => clearTimeout( timeoutID );
},
initialPreviewState: {
isPreview: true,
previewMessage:
'This is a preview message for my custom collection with advanced preview.',
},
},
} );

View File

@ -1204,8 +1204,8 @@ test.describe( 'Product Collection', () => {
await pageObject.goToEditorTemplate( path );
await pageObject.focusProductCollection();
const previewButtonLocator = editor.canvas.locator(
'button[data-test-id="product-collection-preview-button"]'
const previewButtonLocator = editor.canvas.getByTestId(
SELECTORS.previewButtonTestID
);
// The preview button should be visible
@ -1387,3 +1387,235 @@ test.describe( 'Product Collection', () => {
} );
}
} );
/**
* These E2E tests are for `registerProductCollection` which we are exposing
* for 3PDs to register new product collections.
*/
test.describe( 'Testing registerProductCollection', () => {
const MY_REGISTERED_COLLECTIONS = {
myCustomCollection: {
name: 'My Custom Collection',
label: 'Block: My Custom Collection',
},
myCustomCollectionWithPreview: {
name: 'My Custom Collection with Preview',
label: 'Block: My Custom Collection with Preview',
},
myCustomCollectionWithAdvancedPreview: {
name: 'My Custom Collection with Advanced Preview',
label: 'Block: My Custom Collection with Advanced Preview',
},
};
// Activate plugin which registers custom product collections
test.beforeEach( async ( { requestUtils } ) => {
await requestUtils.activatePlugin(
'register-product-collection-tester'
);
} );
test( `Registered collections should be available in Collection chooser`, async ( {
pageObject,
editor,
admin,
} ) => {
await admin.createNewPost();
await editor.insertBlockUsingGlobalInserter( pageObject.BLOCK_NAME );
// Get text of all buttons in the collection chooser
const collectionChooserButtonsTexts = await editor.page
.locator( '.wc-blocks-product-collection__collection-button-title' )
.allTextContents();
// Check if all registered collections are available in the collection chooser
expect(
collectionChooserButtonsTexts.includes(
MY_REGISTERED_COLLECTIONS.myCustomCollection.name
)
).toBeTruthy();
expect(
collectionChooserButtonsTexts.includes(
MY_REGISTERED_COLLECTIONS.myCustomCollectionWithPreview.name
)
).toBeTruthy();
expect(
collectionChooserButtonsTexts.includes(
MY_REGISTERED_COLLECTIONS.myCustomCollectionWithAdvancedPreview
.name
)
).toBeTruthy();
} );
test.describe( 'My Custom Collection', () => {
test( 'Clicking "My Custom Collection" should insert block and show 5 products', async ( {
pageObject,
} ) => {
await pageObject.createNewPostAndInsertBlock(
'myCustomCollection'
);
expect( pageObject.productTemplate ).not.toBeNull();
await expect( pageObject.products ).toHaveCount( 5 );
await expect( pageObject.productImages ).toHaveCount( 5 );
await expect( pageObject.productTitles ).toHaveCount( 5 );
await expect( pageObject.productPrices ).toHaveCount( 5 );
await expect( pageObject.addToCartButtons ).toHaveCount( 5 );
await pageObject.publishAndGoToFrontend();
await expect( pageObject.products ).toHaveCount( 5 );
} );
test( 'Should display properly in Product Catalog template', async ( {
pageObject,
editor,
} ) => {
await pageObject.goToProductCatalogAndInsertCollection(
'myCustomCollection'
);
const block = editor.canvas.getByLabel(
MY_REGISTERED_COLLECTIONS.myCustomCollection.label
);
const products = block
.getByLabel( BLOCK_LABELS.productImage )
.locator( 'visible=true' );
await expect( products ).toHaveCount( 5 );
} );
} );
test.describe( 'My Custom Collection with Preview', () => {
test( 'Clicking "My Custom Collection with Preview" should insert block and show 9 products', async ( {
pageObject,
} ) => {
await pageObject.createNewPostAndInsertBlock(
'myCustomCollectionWithPreview'
);
expect( pageObject.productTemplate ).not.toBeNull();
await expect( pageObject.products ).toHaveCount( 9 );
await expect( pageObject.productImages ).toHaveCount( 9 );
await expect( pageObject.productTitles ).toHaveCount( 9 );
await expect( pageObject.productPrices ).toHaveCount( 9 );
await expect( pageObject.addToCartButtons ).toHaveCount( 9 );
await pageObject.publishAndGoToFrontend();
await expect( pageObject.products ).toHaveCount( 9 );
} );
test( 'Clicking "My Custom Collection with Preview" should show preview', async ( {
pageObject,
editor,
} ) => {
await pageObject.createNewPostAndInsertBlock(
'myCustomCollectionWithPreview'
);
const previewButtonLocator = editor.page.getByTestId(
SELECTORS.previewButtonTestID
);
// The preview button should be visible
await expect( previewButtonLocator ).toBeVisible();
} );
test( 'Should display properly in Product Catalog template', async ( {
pageObject,
editor,
} ) => {
await pageObject.goToProductCatalogAndInsertCollection(
'myCustomCollectionWithPreview'
);
const block = editor.canvas.getByLabel(
MY_REGISTERED_COLLECTIONS.myCustomCollectionWithPreview.label
);
// Check if products are visible
const products = block
.getByLabel( BLOCK_LABELS.productImage )
.locator( 'visible=true' );
await expect( products ).toHaveCount( 9 );
// Check if the preview button is visible
const previewButtonLocator = block.getByTestId(
SELECTORS.previewButtonTestID
);
await expect( previewButtonLocator ).toBeVisible();
} );
} );
test.describe( 'My Custom Collection with Advanced Preview', () => {
test( 'Clicking "My Custom Collection with Advanced Preview" should insert block and show 9 products', async ( {
pageObject,
} ) => {
await pageObject.createNewPostAndInsertBlock(
'myCustomCollectionWithAdvancedPreview'
);
expect( pageObject.productTemplate ).not.toBeNull();
await expect( pageObject.products ).toHaveCount( 9 );
await expect( pageObject.productImages ).toHaveCount( 9 );
await expect( pageObject.productTitles ).toHaveCount( 9 );
await expect( pageObject.productPrices ).toHaveCount( 9 );
await expect( pageObject.addToCartButtons ).toHaveCount( 9 );
await pageObject.publishAndGoToFrontend();
await expect( pageObject.products ).toHaveCount( 9 );
} );
test( 'Clicking "My Custom Collection with Advanced Preview" should show preview for 1 second', async ( {
pageObject,
editor,
page,
} ) => {
await pageObject.createNewPostAndInsertBlock(
'myCustomCollectionWithAdvancedPreview'
);
const previewButtonLocator = editor.page.getByTestId(
SELECTORS.previewButtonTestID
);
// The preview button should be visible
await expect( previewButtonLocator ).toBeVisible();
// Disabling eslint rule because we need to wait for the preview to disappear
// eslint-disable-next-line playwright/no-wait-for-timeout, no-restricted-syntax
await page.waitForTimeout( 1000 );
// The preview button should be hidden
await expect( previewButtonLocator ).toBeHidden();
} );
test( 'Should display properly in Product Catalog template', async ( {
pageObject,
editor,
page,
} ) => {
await pageObject.goToProductCatalogAndInsertCollection(
'myCustomCollectionWithAdvancedPreview'
);
const block = editor.canvas.getByLabel(
MY_REGISTERED_COLLECTIONS.myCustomCollectionWithAdvancedPreview
.label
);
// Check if the preview button is visible
const previewButtonLocator = block.getByTestId(
SELECTORS.previewButtonTestID
);
await expect( previewButtonLocator ).toBeVisible();
// Check if products are visible
const products = block
.getByLabel( BLOCK_LABELS.productImage )
.locator( 'visible=true' );
await expect( products ).toHaveCount( 9 );
// Disabling eslint rule because we need to wait for the preview to disappear
// eslint-disable-next-line playwright/no-wait-for-timeout, no-restricted-syntax
await page.waitForTimeout( 1000 );
// The preview button should be hidden after 1 second
await expect( previewButtonLocator ).toBeHidden();
} );
} );
} );

View File

@ -59,15 +59,19 @@ export const SELECTORS = {
min: 'MIN',
max: 'MAX',
},
previewButtonTestID: 'product-collection-preview-button',
};
type Collections =
export type Collections =
| 'newArrivals'
| 'topRated'
| 'bestSellers'
| 'onSale'
| 'featured'
| 'productCatalog';
| 'productCatalog'
| 'myCustomCollection'
| 'myCustomCollectionWithPreview'
| 'myCustomCollectionWithAdvancedPreview';
const collectionToButtonNameMap = {
newArrivals: 'New Arrivals Recommend your newest products.',
@ -77,6 +81,11 @@ const collectionToButtonNameMap = {
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.',
myCustomCollection: 'My Custom Collection This is a custom collection.',
myCustomCollectionWithPreview:
'My Custom Collection with Preview This is a custom collection with preview.',
myCustomCollectionWithAdvancedPreview:
'My Custom Collection with Advanced Preview This is a custom collection with advanced preview.',
};
class ProductCollectionPage {
@ -206,6 +215,13 @@ class ProductCollectionPage {
await this.refreshLocators( 'editor' );
}
async goToProductCatalogAndInsertCollection( collection: Collections ) {
await this.goToTemplateAndInsertCollection(
'woocommerce/woocommerce//archive-product',
collection
);
}
async goToProductCatalogFrontend() {
await this.page.goto( '/shop' );
await this.refreshLocators( 'frontend' );

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Expose __experimentalRegisterProductCollection in @woocommerce/blocks-registry Package

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add validation for `__experimentalRegisterProductCollection` arguments