New flow of adding Product Collection and basic set of Collections (#42696)

* Migrate PR to Core

* Migrate test changes

* Add Custom Collection with inherit query attribute disabled

* Update copy of Product Catalog collection

* Don't choose default active collection in polaceholder and remove highlight

* Remove inherit query option from inspector controls

* Add collection names to Product Catalog and Custom one

* Unify Collection Chooser between Modal and Placeholder

* Bring back util removed by accident

* Replace the translations domain with woocommerce

* Remove leftovers after merge

* Add pagination and no results to Product Catalog and Custom collections

* Revert textdomain change

* Fix lint error

* Add changelog entry

* Change collection label

* Bring back Inherit query from template

* Remove Custom collection and bring back single Product Collection

* Simplify applying collection

* Make sure Inherit query from template is enabled in archive templates by default and disbaled in posts/pages

* Change incorrect Playwright locator

* Add test for Product Catalog inheriting the query in product archive

* Add tests for recommendation collection hiding the predefined filters

* Add reviews to multiple products

* Update expected products in Top Rated

* Remove rating creation in test env and skip undeterministic tests

* Add skip to Best Sellers test

* Update README.md

* Add more keywords to collections, like 'product collection' to recommendation collections

* Rephrase the README note about Collections registration

* Simplify types

* Rename unchangeableFilters to hideControls

* Fix typo in file name

* Remove 'pattern' references from toolbar files

* Replace hardcoded SCSS color with wc variable

* Remove changelog file from different PR

* Move hideControls to Product Collection ayttributes

* Improve responsiveness of columns in Product Collection Placeholder

* Use admin color pallette in Product Collection Placeholder

* Move Inherit query from template to the top of Inspector Controls

* Change the Collection prefix to woocommerce rather than woocommerce-blocks

* Simplify Placeholder and Modal styles
This commit is contained in:
Karol Manijak 2023-12-27 11:07:27 +01:00 committed by GitHub
parent 2a4c4e8e1e
commit 37155abb0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1315 additions and 249 deletions

View File

@ -1,13 +1,13 @@
{ {
"$schema": "https://schemas.wp.org/trunk/block.json", "$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2, "apiVersion": 2,
"name": "woocommerce/product-collection", "name": "woocommerce/product-collection",
"version": "1.0.0", "version": "1.0.0",
"title": "Product Collection (Beta)", "title": "Product Collection (Beta)",
"description": "Display a collection of products from your store.", "description": "Display a collection of products from your store.",
"category": "woocommerce", "category": "woocommerce",
"keywords": [ "WooCommerce", "Products (Beta)" ], "keywords": [ "WooCommerce", "Products (Beta)" ],
"textdomain": "woocommerce", "textdomain": "woocommerce",
"attributes": { "attributes": {
"queryId": { "queryId": {
"type": "number" "type": "number"
@ -24,6 +24,13 @@
"convertedFromProducts": { "convertedFromProducts": {
"type": "boolean", "type": "boolean",
"default": false "default": false
},
"collection": {
"type": "string"
},
"hideControls": {
"default": [],
"type": "array"
} }
}, },
"providesContext": { "providesContext": {

View File

@ -0,0 +1,47 @@
# Product Collection - Collections
_Note: Collections documented here are internal implementation. It's not a way to register custom Collections, however we're going to expose public API for that._
Collections are a variations of Product Collection block with the predefined attributes which includes:
- UI aspect - you can define layout, number of columns etc.
- Query - specify the filters and sorting of the products
- Inner blocks structure - define the Product Template structure
## Interface
Collections are in fact Variations and they are registered via Variation API. Hence they should follow the BlockVariation type, providing at least:
```typescript
type Collection ={
name: string;
title: string;
icon: Icon;
description: string;
attributes: ProductCollectionAttributes;
innerBlocks: InnerBlockTemplate[];
isActive?:
(blockAttrs: BlockAttributes, variationAttributes: BlockAttributes) => boolean;
}
```
Please be aware you can specify `isActive` function, but if not, the default one will compare the variation's `name` with `attributes.collection` value.
As an example please follow `./new-arrivals.tsx`.
## Collection can hide Inspector Controls filters from users
Let's take New Arrivals as an example. What defines New Arrivals is the product order: from newest to oldest. Users can apply additional filters on top of it, for example, "On Sale" but shouldn't be able to change ordering because that would no longer be New Arrivals Collection.
To achieve this add additional property to collection definition:
```typescript
type Collection = {
...;
hideControls: FilterName[];
}
```
## Registering Collection
To register collection import it in `./index.ts` file and add to the `collections` array.

View File

@ -0,0 +1,64 @@
/**
* External dependencies
*/
import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { Icon, chartBar } from '@wordpress/icons';
/**
* Internal dependencies
*/
import {
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_PRODUCT_TEMPLATE,
} from '../constants';
import { CoreCollectionNames, CoreFilterNames } from '../types';
const collection = {
name: CoreCollectionNames.BEST_SELLERS,
title: __( 'Best Sellers', 'woocommerce' ),
icon: ( <Icon icon={ chartBar } /> ) as BlockIcon,
description: __( 'Recommend your best-selling products.', 'woocommerce' ),
keywords: [ 'best selling', 'product collection' ],
scope: [],
};
const attributes = {
...DEFAULT_ATTRIBUTES,
displayLayout: {
type: 'flex',
columns: 5,
shrinkColumns: true,
},
query: {
...DEFAULT_ATTRIBUTES.query,
inherit: false,
orderBy: 'popularity',
order: 'desc',
perPage: 5,
pages: 1,
},
collection: collection.name,
hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.ORDER ],
};
const heading: InnerBlockTemplate = [
'core/heading',
{
textAlign: 'center',
level: 2,
content: __( 'Best selling products', 'woocommerce' ),
style: { spacing: { margin: { bottom: '1rem' } } },
},
];
const innerBlocks: InnerBlockTemplate[] = [
heading,
INNER_BLOCKS_PRODUCT_TEMPLATE,
];
export default {
...collection,
attributes,
innerBlocks,
};

View File

@ -0,0 +1,63 @@
/**
* External dependencies
*/
import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { Icon, starFilled } from '@wordpress/icons';
/**
* Internal dependencies
*/
import {
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_PRODUCT_TEMPLATE,
} from '../constants';
import { CoreCollectionNames, CoreFilterNames } from '../types';
const collection = {
name: CoreCollectionNames.FEATURED,
title: __( 'Featured', 'woocommerce' ),
icon: ( <Icon icon={ starFilled } /> ) as BlockIcon,
description: __( 'Showcase your featured products.', 'woocommerce' ),
keywords: [ 'product collection' ],
scope: [],
};
const attributes = {
...DEFAULT_ATTRIBUTES,
displayLayout: {
type: 'flex',
columns: 5,
shrinkColumns: true,
},
query: {
...DEFAULT_ATTRIBUTES.query,
inherit: false,
featured: true,
perPage: 5,
pages: 1,
},
collection: collection.name,
hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.FEATURED ],
};
const heading: InnerBlockTemplate = [
'core/heading',
{
textAlign: 'center',
level: 2,
content: __( 'Featured products', 'woocommerce' ),
style: { spacing: { margin: { bottom: '1rem' } } },
},
];
const innerBlocks: InnerBlockTemplate[] = [
heading,
INNER_BLOCKS_PRODUCT_TEMPLATE,
];
export default {
...collection,
attributes,
innerBlocks,
};

View File

@ -0,0 +1,55 @@
/**
* External dependencies
*/
import {
type BlockVariation,
registerBlockVariation,
BlockAttributes,
} from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { CollectionName } from '../types';
import blockJson from '../block.json';
import productCollection from './product-collection';
import newArrivals from './new-arrivals';
import topRated from './top-rated';
import bestSellers from './best-sellers';
import onSale from './on-sale';
import featured from './featured';
const collections: BlockVariation[] = [
productCollection,
featured,
topRated,
onSale,
bestSellers,
newArrivals,
];
export const registerCollections = () => {
collections.forEach( ( collection ) => {
const isActive = (
blockAttrs: BlockAttributes,
variationAttributes: BlockAttributes
) => {
return blockAttrs.collection === variationAttributes.collection;
};
registerBlockVariation( blockJson.name, {
isActive,
...collection,
} );
} );
};
export const getCollectionByName = ( collectionName?: CollectionName ) => {
if ( ! collectionName ) {
return null;
}
return collections.find( ( { name } ) => name === collectionName );
};
export default registerCollections;

View File

@ -0,0 +1,64 @@
/**
* External dependencies
*/
import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { Icon, calendar } from '@wordpress/icons';
/**
* Internal dependencies
*/
import {
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_PRODUCT_TEMPLATE,
} from '../constants';
import { CoreCollectionNames, CoreFilterNames } from '../types';
const collection = {
name: CoreCollectionNames.NEW_ARRIVALS,
title: __( 'New Arrivals', 'woocommerce' ),
icon: ( <Icon icon={ calendar } /> ) as BlockIcon,
description: __( 'Recommend your newest products.', 'woocommerce' ),
keywords: [ 'newest products', 'product collection' ],
scope: [],
};
const attributes = {
...DEFAULT_ATTRIBUTES,
displayLayout: {
type: 'flex',
columns: 5,
shrinkColumns: true,
},
query: {
...DEFAULT_ATTRIBUTES.query,
inherit: false,
orderBy: 'date',
order: 'desc',
perPage: 5,
pages: 1,
},
collection: collection.name,
hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.ORDER ],
};
const heading: InnerBlockTemplate = [
'core/heading',
{
textAlign: 'center',
level: 2,
content: __( 'New arrivals', 'woocommerce' ),
style: { spacing: { margin: { bottom: '1rem' } } },
},
];
const innerBlocks: InnerBlockTemplate[] = [
heading,
INNER_BLOCKS_PRODUCT_TEMPLATE,
];
export default {
...collection,
attributes,
innerBlocks,
};

View File

@ -0,0 +1,66 @@
/**
* External dependencies
*/
import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { Icon, percent } from '@wordpress/icons';
/**
* Internal dependencies
*/
import {
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_PRODUCT_TEMPLATE,
} from '../constants';
import { CoreCollectionNames, CoreFilterNames } from '../types';
const collection = {
name: CoreCollectionNames.ON_SALE,
title: __( 'On Sale', 'woocommerce' ),
icon: ( <Icon icon={ percent } /> ) as BlockIcon,
description: __(
'Highlight products that are currently on sale.',
'woocommerce'
),
keywords: [ 'product collection' ],
scope: [],
};
const attributes = {
...DEFAULT_ATTRIBUTES,
displayLayout: {
type: 'flex',
columns: 5,
shrinkColumns: true,
},
query: {
...DEFAULT_ATTRIBUTES.query,
inherit: false,
woocommerceOnSale: true,
perPage: 5,
pages: 1,
},
collection: collection.name,
hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.ON_SALE ],
};
const heading: InnerBlockTemplate = [
'core/heading',
{
textAlign: 'center',
level: 2,
content: __( 'On sale products', 'woocommerce' ),
style: { spacing: { margin: { bottom: '1rem' } } },
},
];
const innerBlocks: InnerBlockTemplate[] = [
heading,
INNER_BLOCKS_PRODUCT_TEMPLATE,
];
export default {
...collection,
attributes,
innerBlocks,
};

View File

@ -0,0 +1,40 @@
/**
* External dependencies
*/
import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { Icon, loop } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants';
import { CoreCollectionNames } from '../types';
const collection = {
name: CoreCollectionNames.PRODUCT_CATALOG,
title: __( 'Product Catalog', 'woocommerce' ),
icon: ( <Icon icon={ loop } /> ) as BlockIcon,
description:
'Display all products in your catalog. Results can (change to) match the current template, page, or search term.',
keywords: [ 'all products' ],
scope: [],
};
const attributes = {
...DEFAULT_ATTRIBUTES,
query: {
...DEFAULT_ATTRIBUTES.query,
inherit: true,
},
collection: collection.name,
hideControls: [],
};
const innerBlocks: InnerBlockTemplate[] = INNER_BLOCKS_TEMPLATE;
export default {
...collection,
attributes,
innerBlocks,
};

View File

@ -0,0 +1,67 @@
/**
* External dependencies
*/
import type { InnerBlockTemplate, BlockIcon } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { Icon, starEmpty } from '@wordpress/icons';
/**
* Internal dependencies
*/
import {
DEFAULT_ATTRIBUTES,
INNER_BLOCKS_PRODUCT_TEMPLATE,
} from '../constants';
import { CoreCollectionNames, CoreFilterNames } from '../types';
const collection = {
name: CoreCollectionNames.TOP_RATED,
title: __( 'Top Rated', 'woocommerce' ),
icon: ( <Icon icon={ starEmpty } /> ) as BlockIcon,
description: __(
'Recommend products with the highest review ratings.',
'woocommerce'
),
keywords: [ 'product collection' ],
scope: [],
};
const attributes = {
...DEFAULT_ATTRIBUTES,
displayLayout: {
type: 'flex',
columns: 5,
shrinkColumns: true,
},
query: {
...DEFAULT_ATTRIBUTES.query,
inherit: false,
orderBy: 'rating',
order: 'desc',
perPage: 5,
pages: 1,
},
collection: collection.name,
hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.ORDER ],
};
const heading: InnerBlockTemplate = [
'core/heading',
{
textAlign: 'center',
level: 2,
content: __( 'Top rated products', 'woocommerce' ),
style: { spacing: { margin: { bottom: '1rem' } } },
},
];
const innerBlocks: InnerBlockTemplate[] = [
heading,
INNER_BLOCKS_PRODUCT_TEMPLATE,
];
export default {
...collection,
attributes,
innerBlocks,
};

View File

@ -0,0 +1,118 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
import { Button } from '@wordpress/components';
import {
BlockInstance,
createBlock,
// @ts-expect-error Type definitions for this function are missing in Guteberg
createBlocksFromInnerBlocksTemplate,
// @ts-expect-error Type definitions for this function are missing in Guteberg
store as blocksStore,
} from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { type CollectionName, CoreCollectionNames } from '../types';
import blockJson from '../block.json';
import { getCollectionByName } from '../collections';
import { getDefaultProductCollection } from '../constants';
type CollectionButtonProps = {
active?: boolean;
title: string;
icon: string;
description: string;
onClick: () => void;
};
export const applyCollection = (
collectionName: CollectionName,
clientId: string,
replaceBlock: ( clientId: string, block: BlockInstance ) => void
) => {
const collection = getCollectionByName( collectionName );
if ( ! collection ) {
return;
}
const newBlock =
collection.name === CoreCollectionNames.PRODUCT_CATALOG
? getDefaultProductCollection()
: createBlock(
blockJson.name,
collection.attributes,
createBlocksFromInnerBlocksTemplate(
collection.innerBlocks
)
);
replaceBlock( clientId, newBlock );
};
const CollectionButton = ( {
active = false,
title,
icon,
description,
onClick,
}: CollectionButtonProps ) => {
const variant = active ? 'primary' : 'secondary';
return (
<Button
className="wc-blocks-product-collection__collection-button"
variant={ variant }
onClick={ onClick }
>
<div className="wc-blocks-product-collection__collection-button-icon">
{ icon }
</div>
<div className="wc-blocks-product-collection__collection-button-text">
<p className="wc-blocks-product-collection__collection-button-title">
{ title }
</p>
<p className="wc-blocks-product-collection__collection-button-description">
{ description }
</p>
</div>
</Button>
);
};
const CollectionChooser = ( props: {
chosenCollection?: CollectionName | undefined;
onCollectionClick: ( name: string ) => void;
} ) => {
const { chosenCollection, onCollectionClick } = props;
// Get Collections
const blockCollections = [
...useSelect( ( select ) => {
// @ts-expect-error Type definitions are missing
// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wordpress__blocks/store/selectors.d.ts
const { getBlockVariations } = select( blocksStore );
return getBlockVariations( blockJson.name );
}, [] ),
];
return (
<div className="wc-blocks-product-collection__collections-section">
{ blockCollections.map( ( { name, title, icon, description } ) => (
<CollectionButton
active={ chosenCollection === name }
key={ name }
title={ title }
description={ description }
icon={ icon }
onClick={ () => onCollectionClick( name ) }
/>
) ) }
</div>
);
};
export default CollectionChooser;

View File

@ -0,0 +1,71 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { Modal, Button } from '@wordpress/components';
import { store as blockEditorStore } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import CollectionChooser, { applyCollection } from './collection-chooser';
import type { ProductCollectionAttributes } from '../types';
const PatternSelectionModal = ( props: {
clientId: string;
attributes: ProductCollectionAttributes;
closePatternSelectionModal: () => void;
} ) => {
const { clientId, attributes } = props;
// @ts-expect-error Type definitions for this function are missing
// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wordpress__blocks/store/actions.d.ts
const { replaceBlock } = useDispatch( blockEditorStore );
const [ chosenCollection, selectCollectionName ] = useState(
attributes.collection
);
const onContinueClick = () => {
if ( chosenCollection ) {
applyCollection( chosenCollection, clientId, replaceBlock );
}
};
return (
<Modal
overlayClassName="wc-blocks-product-collection__modal"
title={ __( 'Choose a collection', 'woocommerce' ) }
onRequestClose={ props.closePatternSelectionModal }
// @ts-expect-error Type definitions are missing in the version we are using i.e. 19.1.5,
size={ 'large' }
>
<div className="wc-blocks-product-collection__content">
<p className="wc-blocks-product-collection__subtitle">
{ __(
"Pick what products are shown. Don't worry, you can switch and tweak this collection any time.",
'woocommerce'
) }
</p>
<CollectionChooser
chosenCollection={ chosenCollection }
onCollectionClick={ selectCollectionName }
/>
<div className="wc-blocks-product-collection__footer">
<Button
variant="tertiary"
onClick={ props.closePatternSelectionModal }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<Button variant="primary" onClick={ onContinueClick }>
{ __( 'Continue', 'woocommerce' ) }
</Button>
</div>
</div>
</Modal>
);
};
export default PatternSelectionModal;

View File

@ -1,3 +1,9 @@
$max-columns: 3;
$min-button-width: 250px;
$gap-count: calc(#{ $max-columns } - 1);
$total-gap-width: calc(#{ $gap-count } * #{ $gap-small });
$max-button-width: calc((100% - #{ $total-gap-width }) / #{ $max-columns });
.wc-block-editor-product-collection-inspector-toolspanel__filters { .wc-block-editor-product-collection-inspector-toolspanel__filters {
.wc-block-editor-product-collection-inspector__taxonomy-control:not(:last-child) { .wc-block-editor-product-collection-inspector__taxonomy-control:not(:last-child) {
margin-bottom: $grid-unit-30; margin-bottom: $grid-unit-30;
@ -10,18 +16,76 @@
} }
} }
.wc-blocks-product-collection__selection-modal { // Need to override high-specificity styles
.block-editor-block-patterns-list { .wc-blocks-product-collection__placeholder.is-medium {
column-count: 3; .components-placeholder__fieldset {
column-gap: $grid-unit-30; display: block;
}
@include breakpoint("<1280px") { .components-button.wc-blocks-product-collection__collection-button {
column-count: 2; margin: 0;
}
}
.wc-blocks-product-collection__placeholder,
.wc-blocks-product-collection__modal {
.wc-blocks-product-collection__selection-subtitle {
margin-bottom: $gap-large;
}
.wc-blocks-product-collection__collections-section {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(max(#{ $min-button-width }, #{ $max-button-width }), 1fr));
grid-auto-rows: 1fr;
grid-gap: $gap-small;
margin: $gap-large auto;
max-width: 1000px;
}
.wc-blocks-product-collection__collection-button {
color: var(--wp-admin-theme-color);
display: flex;
align-items: flex-start;
height: auto;
border-radius: $universal-border-radius;
box-sizing: border-box;
padding: $gap-smallest $gap-small;
margin: 0;
&.is-primary {
box-shadow: 0 0 0 2px var(--wp-admin-theme-color, #3858e9);
color: var(--wp-admin-theme-color-darker-20);
background-color: var(--wc-content-bg, #fff);
&:hover {
background-color: var(--wc-content-bg, #fff);
color: var(--wp-admin-theme-color-darker-20);
}
} }
@include breakpoint("<782px") { .wc-blocks-product-collection__collection-button-icon {
column-count: 1; 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;
} }
} }

View File

@ -1,74 +1,52 @@
/** /**
* External dependencies * External dependencies
*/ */
import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; import { store as blockEditorStore } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks'; import { BlockEditProps } from '@wordpress/blocks';
import { useInstanceId } from '@wordpress/compose'; import { useState } from '@wordpress/element';
import { useEffect } from '@wordpress/element'; import { useSelect } from '@wordpress/data';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import type { import type { ProductCollectionAttributes } from '../types';
ProductCollectionAttributes, import ProductCollectionPlaceholder from './product-collection-placeholder';
ProductCollectionQuery, import ProductCollectionContent from './product-collection-content';
} from '../types'; import CollectionSelectionModal from './collection-selection-modal';
import InspectorControls from './inspector-controls';
import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants';
import './editor.scss'; import './editor.scss';
import { getDefaultValueOfInheritQueryFromTemplate } from '../utils';
import ToolbarControls from './toolbar-controls';
const Edit = ( props: BlockEditProps< ProductCollectionAttributes > ) => { const Edit = ( props: BlockEditProps< ProductCollectionAttributes > ) => {
const { attributes, setAttributes } = props; const { clientId, attributes } = props;
const { queryId } = attributes;
const blockProps = useBlockProps(); const [ isSelectionModalOpen, setIsSelectionModalOpen ] = useState( false );
const innerBlocksProps = useInnerBlocksProps( blockProps, { const hasInnerBlocks = useSelect(
template: INNER_BLOCKS_TEMPLATE, ( select ) =>
} ); !! select( blockEditorStore ).getBlocks( clientId ).length,
[ clientId ]
);
const instanceId = useInstanceId( Edit ); const Component = hasInnerBlocks
? ProductCollectionContent
// We need this for multi-query block pagination. : ProductCollectionPlaceholder;
// 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 ( return (
<div { ...blockProps }> <>
<InspectorControls { ...props } /> <Component
<ToolbarControls { ...props } /> { ...props }
<div { ...innerBlocksProps } /> openCollectionSelectionModal={ () =>
</div> setIsSelectionModalOpen( true )
}
/>
{ isSelectionModalOpen && (
<CollectionSelectionModal
clientId={ clientId }
attributes={ attributes }
closePatternSelectionModal={ () =>
setIsSelectionModalOpen( false )
}
/>
) }
</>
); );
}; };

View File

@ -13,7 +13,7 @@ import {
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { DisplayLayoutToolbarProps } from '../../types'; import { DisplayLayoutControlProps } from '../../types';
import { getDefaultDisplayLayout } from '../../constants'; import { getDefaultDisplayLayout } from '../../constants';
const columnsLabel = __( 'Columns', 'woocommerce' ); const columnsLabel = __( 'Columns', 'woocommerce' );
@ -23,7 +23,7 @@ const toggleHelp = __(
'woocommerce' 'woocommerce'
); );
const ColumnsControl = ( props: DisplayLayoutToolbarProps ) => { const ColumnsControl = ( props: DisplayLayoutControlProps ) => {
const { type, columns, shrinkColumns } = props.displayLayout; const { type, columns, shrinkColumns } = props.displayLayout;
const showColumnsControl = type === 'flex'; const showColumnsControl = type === 'flex';

View File

@ -5,7 +5,7 @@ import type { BlockEditProps } from '@wordpress/blocks';
import { InspectorControls } from '@wordpress/block-editor'; import { InspectorControls } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { type ElementType, useMemo } from '@wordpress/element'; import { type ElementType, useMemo } from '@wordpress/element';
import { EditorBlock } from '@woocommerce/types'; import { EditorBlock, isEmpty } from '@woocommerce/types';
import { addFilter } from '@wordpress/hooks'; import { addFilter } from '@wordpress/hooks';
import { ProductCollectionFeedbackPrompt } from '@woocommerce/editor-components/feedback-prompt'; import { ProductCollectionFeedbackPrompt } from '@woocommerce/editor-components/feedback-prompt';
import { import {
@ -25,7 +25,11 @@ import {
* Internal dependencies * Internal dependencies
*/ */
import metadata from '../../block.json'; import metadata from '../../block.json';
import { ProductCollectionAttributes } from '../../types'; import {
ProductCollectionAttributes,
CoreFilterNames,
FilterName,
} from '../../types';
import { setQueryAttribute } from '../../utils'; import { setQueryAttribute } from '../../utils';
import { DEFAULT_FILTERS, getDefaultSettings } from '../../constants'; import { DEFAULT_FILTERS, getDefaultSettings } from '../../constants';
import UpgradeNotice from './upgrade-notice'; import UpgradeNotice from './upgrade-notice';
@ -43,12 +47,25 @@ import FeaturedProductsControl from './featured-products-control';
import CreatedControl from './created-control'; import CreatedControl from './created-control';
import PriceRangeControl from './price-range-control'; import PriceRangeControl from './price-range-control';
const prepareShouldShowFilter =
( hideControls: FilterName[] ) => ( filter: FilterName ) => {
return ! hideControls.includes( filter );
};
const ProductCollectionInspectorControls = ( const ProductCollectionInspectorControls = (
props: BlockEditProps< ProductCollectionAttributes > props: BlockEditProps< ProductCollectionAttributes >
) => { ) => {
const query = props.attributes.query; const { query, collection, hideControls } = props.attributes;
const inherit = query?.inherit; const inherit = query?.inherit;
const displayQueryControls = inherit === false; const shouldShowFilter = prepareShouldShowFilter( hideControls );
const showQueryControls = inherit === false;
const showInheritQueryControls =
isEmpty( collection ) || shouldShowFilter( CoreFilterNames.INHERIT );
const showOrderControl =
showQueryControls && shouldShowFilter( CoreFilterNames.ORDER );
const showFeaturedControl = shouldShowFilter( CoreFilterNames.FEATURED );
const showOnSaleControl = shouldShowFilter( CoreFilterNames.ON_SALE );
const setQueryAttributeBind = useMemo( const setQueryAttributeBind = useMemo(
() => setQueryAttribute.bind( null, props ), () => setQueryAttribute.bind( null, props ),
@ -76,15 +93,17 @@ const ProductCollectionInspectorControls = (
props.setAttributes( defaultSettings ); props.setAttributes( defaultSettings );
} } } }
> >
{ showInheritQueryControls && (
<InheritQueryControl { ...queryControlProps } />
) }
<LayoutOptionsControl { ...displayControlProps } /> <LayoutOptionsControl { ...displayControlProps } />
<ColumnsControl { ...displayControlProps } /> <ColumnsControl { ...displayControlProps } />
<InheritQueryControl { ...queryControlProps } /> { showOrderControl && (
{ displayQueryControls ? (
<OrderByControl { ...queryControlProps } /> <OrderByControl { ...queryControlProps } />
) : null } ) }
</ToolsPanel> </ToolsPanel>
{ displayQueryControls ? ( { showQueryControls ? (
<ToolsPanel <ToolsPanel
label={ __( 'Filters', 'woocommerce' ) } label={ __( 'Filters', 'woocommerce' ) }
resetAll={ ( resetAllFilters: ( () => void )[] ) => { resetAll={ ( resetAllFilters: ( () => void )[] ) => {
@ -95,13 +114,17 @@ const ProductCollectionInspectorControls = (
} } } }
className="wc-block-editor-product-collection-inspector-toolspanel__filters" className="wc-block-editor-product-collection-inspector-toolspanel__filters"
> >
<OnSaleControl { ...queryControlProps } /> { showOnSaleControl && (
<OnSaleControl { ...queryControlProps } />
) }
<StockStatusControl { ...queryControlProps } /> <StockStatusControl { ...queryControlProps } />
<HandPickedProductsControl { ...queryControlProps } /> <HandPickedProductsControl { ...queryControlProps } />
<KeywordControl { ...queryControlProps } /> <KeywordControl { ...queryControlProps } />
<AttributesControl { ...queryControlProps } /> <AttributesControl { ...queryControlProps } />
<TaxonomyControls { ...queryControlProps } /> <TaxonomyControls { ...queryControlProps } />
<FeaturedProductsControl { ...queryControlProps } /> { showFeaturedControl && (
<FeaturedProductsControl { ...queryControlProps } />
) }
<CreatedControl { ...queryControlProps } /> <CreatedControl { ...queryControlProps } />
<PriceRangeControl { ...queryControlProps } /> <PriceRangeControl { ...queryControlProps } />
</ToolsPanel> </ToolsPanel>
@ -202,8 +225,4 @@ export const withUpgradeNoticeControls =
); );
}; };
addFilter( addFilter( 'editor.BlockEdit', metadata.name, withUpgradeNoticeControls );
'editor.BlockEdit',
'woocommerce/product-collection',
withUpgradeNoticeControls
);

View File

@ -19,7 +19,7 @@ import {
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { DisplayLayoutToolbarProps, LayoutOptions } from '../../types'; import { DisplayLayoutControlProps, LayoutOptions } from '../../types';
const getHelpText = ( layoutOptions: LayoutOptions ) => { const getHelpText = ( layoutOptions: LayoutOptions ) => {
switch ( layoutOptions ) { switch ( layoutOptions ) {
@ -37,7 +37,7 @@ const getHelpText = ( layoutOptions: LayoutOptions ) => {
const DEFAULT_VALUE = LayoutOptions.GRID; const DEFAULT_VALUE = LayoutOptions.GRID;
const LayoutOptionsControl = ( props: DisplayLayoutToolbarProps ) => { const LayoutOptionsControl = ( props: DisplayLayoutControlProps ) => {
const { type, columns, shrinkColumns } = props.displayLayout; const { type, columns, shrinkColumns } = props.displayLayout;
const setDisplayLayout = ( displayLayout: LayoutOptions ) => { const setDisplayLayout = ( displayLayout: LayoutOptions ) => {
props.setAttributes( { props.setAttributes( {

View File

@ -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 (
<div { ...blockProps }>
<InspectorControls { ...props } />
<ToolbarControls { ...props } />
<div { ...innerBlocksProps } />
</div>
);
};
export default ProductCollectionContent;

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
store as blockEditorStore,
useBlockProps,
} from '@wordpress/block-editor';
import { Placeholder } from '@wordpress/components';
import { useDispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import CollectionChooser, { applyCollection } from './collection-chooser';
import type {
CollectionName,
ProductCollectionEditComponentProps,
} from '../types';
import Icon from '../icon';
const ProductCollectionPlaceholder = (
props: ProductCollectionEditComponentProps
) => {
const blockProps = useBlockProps();
const { clientId } = props;
// @ts-expect-error Type definitions for this function are missing
// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wordpress__blocks/store/actions.d.ts
const { replaceBlock } = useDispatch( blockEditorStore );
const onCollectionClick = ( collectionName: CollectionName ) =>
applyCollection( collectionName, clientId, replaceBlock );
return (
<div { ...blockProps }>
<Placeholder
className="wc-blocks-product-collection__placeholder"
icon={ Icon }
label={ __( 'Product Collection', 'woocommerce' ) }
instructions={ __(
"Choose a collection to get started. Don't worry, you can change and tweak this any time.",
'woocommerce'
) }
>
<CollectionChooser onCollectionClick={ onCollectionClick } />
</Placeholder>
</div>
);
};
export default ProductCollectionPlaceholder;

View File

@ -0,0 +1,19 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
const CollectionChooserToolbar = ( props: {
openCollectionSelectionModal: () => void;
} ) => {
return (
<ToolbarGroup>
<ToolbarButton onClick={ props.openCollectionSelectionModal }>
{ __( 'Choose collection', 'woocommerce' ) }
</ToolbarButton>
</ToolbarGroup>
);
};
export default CollectionChooserToolbar;

View File

@ -9,12 +9,12 @@ import { list, grid } from '@wordpress/icons';
* Internal dependencies * Internal dependencies
*/ */
import { import {
DisplayLayoutToolbarProps, DisplayLayoutControlProps,
ProductCollectionDisplayLayout, ProductCollectionDisplayLayout,
LayoutOptions, LayoutOptions,
} from '../../types'; } from '../../types';
const DisplayLayoutToolbar = ( props: DisplayLayoutToolbarProps ) => { const DisplayLayoutToolbar = ( props: DisplayLayoutControlProps ) => {
const { type, columns, shrinkColumns } = props.displayLayout; const { type, columns, shrinkColumns } = props.displayLayout;
const setDisplayLayout = ( const setDisplayLayout = (
displayLayout: ProductCollectionDisplayLayout displayLayout: ProductCollectionDisplayLayout

View File

@ -1,27 +1,22 @@
/** /**
* External dependencies * External dependencies
*/ */
import type { BlockEditProps } from '@wordpress/blocks'; import { useMemo } from '@wordpress/element';
import { useMemo, useState } from '@wordpress/element';
import { BlockControls } from '@wordpress/block-editor'; import { BlockControls } from '@wordpress/block-editor';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { setQueryAttribute } from '../../utils'; import { setQueryAttribute } from '../../utils';
import { ProductCollectionAttributes } from '../../types';
import DisplaySettingsToolbar from './display-settings-toolbar'; import DisplaySettingsToolbar from './display-settings-toolbar';
import DisplayLayoutToolbar from './display-layout-toolbar'; import DisplayLayoutToolbar from './display-layout-toolbar';
import PatternChooserToolbar from './pattern-chooser-toolbar'; import CollectionChooserToolbar from './collection-chooser-toolbar';
import PatternSelectionModal from './pattern-selection-modal'; import type { ProductCollectionEditComponentProps } from '../../types';
export default function ToolbarControls( export default function ToolbarControls(
props: BlockEditProps< ProductCollectionAttributes > props: ProductCollectionEditComponentProps
) { ) {
const [ isPatternSelectionModalOpen, setIsPatternSelectionModalOpen ] = const { attributes, openCollectionSelectionModal, setAttributes } = props;
useState( false );
const { attributes, clientId, setAttributes } = props;
const { query, displayLayout } = attributes; const { query, displayLayout } = attributes;
const setQueryAttributeBind = useMemo( const setQueryAttributeBind = useMemo(
@ -31,10 +26,8 @@ export default function ToolbarControls(
return ( return (
<BlockControls> <BlockControls>
<PatternChooserToolbar <CollectionChooserToolbar
openPatternSelectionModal={ () => openCollectionSelectionModal={ openCollectionSelectionModal }
setIsPatternSelectionModalOpen( true )
}
/> />
{ ! query.inherit && ( { ! query.inherit && (
<> <>
@ -48,15 +41,6 @@ export default function ToolbarControls(
/> />
</> </>
) } ) }
{ isPatternSelectionModalOpen && (
<PatternSelectionModal
clientId={ clientId }
query={ query }
closePatternSelectionModal={ () =>
setIsPatternSelectionModalOpen( false )
}
/>
) }
</BlockControls> </BlockControls>
); );
} }

View File

@ -1,19 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
const DisplayLayoutControl = ( props: {
openPatternSelectionModal: () => void;
} ) => {
return (
<ToolbarGroup>
<ToolbarButton onClick={ props.openPatternSelectionModal }>
{ __( 'Choose pattern', 'woocommerce' ) }
</ToolbarButton>
</ToolbarGroup>
);
};
export default DisplayLayoutControl;

View File

@ -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 (
<Modal
overlayClassName="wc-blocks-product-collection__selection-modal"
title={ __( 'Choose a pattern', 'woocommerce' ) }
onRequestClose={ props.closePatternSelectionModal }
isFullScreen={ true }
>
<div className="wc-blocks-product-collection__selection-content">
<BlockPatternsList
blockPatterns={ blockPatterns }
shownPatterns={ blockPatterns }
onClickPattern={ onClickPattern }
/>
</div>
</Modal>
);
};
export default DisplayLayoutControl;

View File

@ -12,6 +12,7 @@ import save from './save';
import icon from './icon'; import icon from './icon';
import registerProductSummaryVariation from './variations/elements/product-summary'; import registerProductSummaryVariation from './variations/elements/product-summary';
import registerProductTitleVariation from './variations/elements/product-title'; import registerProductTitleVariation from './variations/elements/product-title';
import registerCollections from './collections';
registerBlockType( metadata, { registerBlockType( metadata, {
icon, icon,
@ -20,3 +21,4 @@ registerBlockType( metadata, {
} ); } );
registerProductSummaryVariation(); registerProductSummaryVariation();
registerProductTitleVariation(); registerProductTitleVariation();
registerCollections();

View File

@ -1,16 +1,16 @@
{ {
"$schema": "https://schemas.wp.org/trunk/block.json", "$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3, "apiVersion": 3,
"name": "woocommerce/product-collection-no-results", "name": "woocommerce/product-collection-no-results",
"title": "No results", "title": "No results",
"version": "1.0.0", "version": "1.0.0",
"category": "woocommerce", "category": "woocommerce",
"description": "The contents of this block will display when there are no products found.", "description": "The contents of this block will display when there are no products found.",
"textdomain": "woocommerce", "textdomain": "woocommerce",
"keywords": [ "Product Collection" ], "keywords": [ "Product Collection" ],
"usesContext": [ "queryId", "query" ], "usesContext": [ "queryId", "query" ],
"ancestor": [ "woocommerce/product-collection" ], "ancestor": [ "woocommerce/product-collection" ],
"supports": { "supports": {
"align": true, "align": true,
"reusable": false, "reusable": false,
"html": false, "html": false,

View File

@ -1,7 +1,8 @@
/** /**
* External dependencies * External dependencies
*/ */
import { AttributeMetadata } from '@woocommerce/types'; import type { BlockEditProps } from '@wordpress/blocks';
import { type AttributeMetadata } from '@woocommerce/types';
export interface ProductCollectionAttributes { export interface ProductCollectionAttributes {
query: ProductCollectionQuery; query: ProductCollectionQuery;
@ -15,6 +16,8 @@ export interface ProductCollectionAttributes {
displayLayout: ProductCollectionDisplayLayout; displayLayout: ProductCollectionDisplayLayout;
tagName: string; tagName: string;
convertedFromProducts: boolean; convertedFromProducts: boolean;
collection?: string;
hideControls: FilterName[];
} }
export enum LayoutOptions { export enum LayoutOptions {
@ -80,6 +83,11 @@ export interface ProductCollectionQuery {
priceRange?: undefined | PriceRange; priceRange?: undefined | PriceRange;
} }
export type ProductCollectionEditComponentProps =
BlockEditProps< ProductCollectionAttributes > & {
openCollectionSelectionModal: () => void;
};
export type TProductCollectionOrder = 'asc' | 'desc'; export type TProductCollectionOrder = 'asc' | 'desc';
export type TProductCollectionOrderBy = export type TProductCollectionOrderBy =
| 'date' | 'date'
@ -87,7 +95,7 @@ export type TProductCollectionOrderBy =
| 'popularity' | 'popularity'
| 'rating'; | 'rating';
export type DisplayLayoutToolbarProps = { export type DisplayLayoutControlProps = {
displayLayout: ProductCollectionDisplayLayout; displayLayout: ProductCollectionDisplayLayout;
setAttributes: ( attrs: Partial< ProductCollectionAttributes > ) => void; setAttributes: ( attrs: Partial< ProductCollectionAttributes > ) => void;
}; };
@ -95,3 +103,29 @@ export type QueryControlProps = {
query: ProductCollectionQuery; query: ProductCollectionQuery;
setQueryAttribute: ( attrs: Partial< ProductCollectionQuery > ) => void; setQueryAttribute: ( attrs: Partial< ProductCollectionQuery > ) => void;
}; };
export enum CoreCollectionNames {
PRODUCT_CATALOG = 'woocommerce/product-collection/product-catalog',
CUSTOM = 'woocommerce/product-collection/custom',
BEST_SELLERS = 'woocommerce/product-collection/best-sellers',
FEATURED = 'woocommerce/product-collection/featured',
NEW_ARRIVALS = 'woocommerce/product-collection/new-arrivals',
ON_SALE = 'woocommerce/product-collection/on-sale',
TOP_RATED = 'woocommerce/product-collection/top-rated',
}
export enum CoreFilterNames {
ATTRIBUTES = 'attributes',
CREATED = 'created',
FEATURED = 'featured',
HAND_PICKED = 'hand-picked',
INHERIT = 'inherit',
KEYWORD = 'keyword',
ON_SALE = 'on-sale',
ORDER = 'order',
STOCK_STATUS = 'stock-status',
TAXONOMY = 'taxonomy',
}
export type CollectionName = CoreCollectionNames | string;
export type FilterName = CoreFilterNames | string;

View File

@ -92,7 +92,6 @@ const test = base.extend< { pageObject: ProductCollectionPage } >( {
templateApiUtils, templateApiUtils,
editorUtils, editorUtils,
} ); } );
await pageObject.createNewPostAndInsertBlock();
await use( pageObject ); await use( pageObject );
}, },
} ); } );

View File

@ -237,7 +237,7 @@ test.describe( 'Product Collection', () => {
test( 'Inherit query from template should work as expected in Product Catalog template', async ( { test( 'Inherit query from template should work as expected in Product Catalog template', async ( {
pageObject, pageObject,
} ) => { } ) => {
await pageObject.goToProductCatalogAndInsertBlock(); await pageObject.goToProductCatalogAndInsertCollection();
const sidebarSettings = const sidebarSettings =
await pageObject.locateSidebarSettings(); await pageObject.locateSidebarSettings();
@ -292,6 +292,7 @@ test.describe( 'Product Collection', () => {
test( 'Toolbar -> Items per page, offset & max page to show', async ( { test( 'Toolbar -> Items per page, offset & max page to show', async ( {
pageObject, pageObject,
} ) => { } ) => {
await pageObject.clickDisplaySettings();
await pageObject.setDisplaySettings( { await pageObject.setDisplaySettings( {
itemsPerPage: 3, itemsPerPage: 3,
offset: 0, offset: 0,
@ -382,4 +383,204 @@ test.describe( 'Product Collection', () => {
); );
} ); } );
} ); } );
test.describe( 'Collections', () => {
test( 'New Arrivals Collection can be added and displays proper products', async ( {
pageObject,
} ) => {
await pageObject.createNewPostAndInsertBlock( 'newArrivals' );
const newArrivalsProducts = [
'WordPress Pennant',
'Logo Collection',
'Beanie with Logo',
'T-Shirt with Logo',
'Single',
];
await expect( pageObject.products ).toHaveCount( 5 );
await expect( pageObject.productTitles ).toHaveText(
newArrivalsProducts
);
await pageObject.publishAndGoToFrontend();
await expect( pageObject.products ).toHaveCount( 5 );
} );
// When creating reviews programmatically the ratings are not propagated
// properly so products order by rating is undeterministic in test env.
// eslint-disable-next-line playwright/no-skipped-test
test.skip( 'Top Rated Collection can be added and displays proper products', async ( {
pageObject,
} ) => {
await pageObject.createNewPostAndInsertBlock( 'topRated' );
const topRatedProducts = [
'V Neck T Shirt',
'Hoodie',
'Hoodie with Logo',
'T-Shirt',
'Beanie',
];
await expect( pageObject.products ).toHaveCount( 5 );
await expect( pageObject.productTitles ).toHaveText(
topRatedProducts
);
await pageObject.publishAndGoToFrontend();
await expect( pageObject.products ).toHaveCount( 5 );
} );
// There's no orders in test env so the order of Best Sellers
// is undeterministic in test env. Requires further work.
// eslint-disable-next-line playwright/no-skipped-test
test.skip( 'Best Sellers Collection can be added and displays proper products', async ( {
pageObject,
} ) => {
await pageObject.createNewPostAndInsertBlock( 'bestSellers' );
const bestSellersProducts = [
'Album',
'Hoodie',
'Single',
'Hoodie with Logo',
'T-Shirt with Logo',
];
await expect( pageObject.products ).toHaveCount( 5 );
await expect( pageObject.productTitles ).toHaveText(
bestSellersProducts
);
await pageObject.publishAndGoToFrontend();
await expect( pageObject.products ).toHaveCount( 5 );
} );
test( 'On Sale Collection can be added and displays proper products', async ( {
pageObject,
} ) => {
await pageObject.createNewPostAndInsertBlock( 'onSale' );
const onSaleProducts = [
'Beanie',
'Beanie with Logo',
'Belt',
'Cap',
'Hoodie',
];
await expect( pageObject.products ).toHaveCount( 5 );
await expect( pageObject.productTitles ).toHaveText(
onSaleProducts
);
await pageObject.publishAndGoToFrontend();
await expect( pageObject.products ).toHaveCount( 5 );
} );
test( 'Featured Collection can be added and displays proper products', async ( {
pageObject,
} ) => {
await pageObject.createNewPostAndInsertBlock( 'featured' );
const featuredProducts = [
'Cap',
'Hoodie with Zipper',
'Sunglasses',
'V-Neck T-Shirt',
];
await expect( pageObject.products ).toHaveCount( 4 );
await expect( pageObject.productTitles ).toHaveText(
featuredProducts
);
await pageObject.publishAndGoToFrontend();
await expect( pageObject.products ).toHaveCount( 4 );
} );
test( "Product Catalog Collection can be added in post and doesn't inherit query from template", async ( {
pageObject,
} ) => {
await pageObject.createNewPostAndInsertBlock( 'productCatalog' );
const sidebarSettings = await pageObject.locateSidebarSettings();
const input = sidebarSettings.locator(
`${ SELECTORS.inheritQueryFromTemplateControl } input`
);
await expect( input ).toBeHidden();
await expect( pageObject.products ).toHaveCount( 9 );
await pageObject.publishAndGoToFrontend();
await expect( pageObject.products ).toHaveCount( 9 );
} );
test( 'Product Catalog Collection can be added in product archive and inherits query from template', async ( {
pageObject,
} ) => {
await pageObject.goToProductCatalogAndInsertCollection(
'productCatalog'
);
const sidebarSettings = await pageObject.locateSidebarSettings();
const input = sidebarSettings.locator(
`${ SELECTORS.inheritQueryFromTemplateControl } input`
);
await expect( input ).toBeChecked();
} );
test.describe( 'Have hidden implementation in UI', () => {
test( 'New Arrivals', async ( { pageObject } ) => {
await pageObject.createNewPostAndInsertBlock( 'newArrivals' );
const input = await pageObject.getOrderByElement();
await expect( input ).toBeHidden();
} );
test( 'Top Rated', async ( { pageObject } ) => {
await pageObject.createNewPostAndInsertBlock( 'topRated' );
const input = await pageObject.getOrderByElement();
await expect( input ).toBeHidden();
} );
test( 'Best Sellers', async ( { pageObject } ) => {
await pageObject.createNewPostAndInsertBlock( 'bestSellers' );
const input = await pageObject.getOrderByElement();
await expect( input ).toBeHidden();
} );
test( 'On Sale', async ( { pageObject } ) => {
await pageObject.createNewPostAndInsertBlock( 'onSale' );
const sidebarSettings =
await pageObject.locateSidebarSettings();
const input = sidebarSettings.getByLabel(
SELECTORS.onSaleControlLabel
);
await expect( input ).toBeHidden();
} );
test( 'Featured', async ( { pageObject } ) => {
await pageObject.createNewPostAndInsertBlock( 'featured' );
const sidebarSettings =
await pageObject.locateSidebarSettings();
const input = sidebarSettings.getByLabel(
SELECTORS.featuredControlLabel
);
await expect( input ).toBeHidden();
} );
} );
} );
} ); } );

View File

@ -4,7 +4,6 @@
import { Locator, Page } from '@playwright/test'; import { Locator, Page } from '@playwright/test';
import { TemplateApiUtils, EditorUtils } from '@woocommerce/e2e-utils'; import { TemplateApiUtils, EditorUtils } from '@woocommerce/e2e-utils';
import { Editor, Admin } from '@wordpress/e2e-test-utils-playwright'; 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 = { export const SELECTORS = {
productTemplate: '.wc-block-product-template', productTemplate: '.wc-block-product-template',
@ -27,6 +26,7 @@ export const SELECTORS = {
onFrontend: '.wp-block-query-pagination', onFrontend: '.wp-block-query-pagination',
}, },
onSaleControlLabel: 'Show only products on sale', onSaleControlLabel: 'Show only products on sale',
featuredControlLabel: 'Show only featured products',
inheritQueryFromTemplateControl: inheritQueryFromTemplateControl:
'.wc-block-product-collection__inherit-query-control', '.wc-block-product-collection__inherit-query-control',
shrinkColumnsToFit: 'Responsive', shrinkColumnsToFit: 'Responsive',
@ -34,6 +34,24 @@ export const SELECTORS = {
productSearchButton: '.wp-block-search__button wp-element-button', productSearchButton: '.wp-block-search__button wp-element-button',
}; };
type Collections =
| 'newArrivals'
| 'topRated'
| 'bestSellers'
| 'onSale'
| 'featured'
| 'productCatalog';
const collectionToButtonNameMap = {
newArrivals: 'New Arrivals Recommend your newest products.',
topRated: 'Top Rated Recommend products with the highest review ratings.',
bestSellers: 'Best Sellers Recommend your best-selling products.',
onSale: 'On Sale Highlight products that are currently on sale.',
featured: 'Featured Showcase your featured products.',
productCatalog:
'Product Catalog Display all products in your catalog. Results can (change to) match the current template, page, or search term.',
};
class ProductCollectionPage { class ProductCollectionPage {
private BLOCK_NAME = 'woocommerce/product-collection'; private BLOCK_NAME = 'woocommerce/product-collection';
private page: Page; private page: Page;
@ -69,11 +87,33 @@ class ProductCollectionPage {
this.editorUtils = editorUtils; 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.admin.createNewPost( { legacyCanvas: true } );
await this.editor.insertBlock( { await this.editor.insertBlock( {
name: this.BLOCK_NAME, name: this.BLOCK_NAME,
} ); } );
await this.chooseCollectionInPost( collection );
await this.refreshLocators( 'editor' ); await this.refreshLocators( 'editor' );
} }
@ -85,19 +125,22 @@ class ProductCollectionPage {
await this.refreshLocators( 'frontend' ); await this.refreshLocators( 'frontend' );
} }
async replaceProductsWithProductCollectionInTemplate( template: string ) { async replaceProductsWithProductCollectionInTemplate(
template: string,
collection?: Collections
) {
await this.templateApiUtils.revertTemplate( template ); await this.templateApiUtils.revertTemplate( template );
await this.admin.visitSiteEditor( { await this.admin.visitSiteEditor( {
postId: template, postId: template,
postType: 'wp_template', postType: 'wp_template',
} ); } );
await this.editorUtils.waitForSiteEditorFinishLoading();
await this.editorUtils.enterEditMode(); await this.editorUtils.enterEditMode();
await this.editorUtils.replaceBlockByBlockName( await this.editorUtils.replaceBlockByBlockName(
'core/query', 'core/query',
'woocommerce/product-collection' 'woocommerce/product-collection'
); );
await this.chooseCollectionInTemplate( collection );
await this.editor.saveSiteEditorEntities(); await this.editor.saveSiteEditorEntities();
} }
@ -105,11 +148,7 @@ class ProductCollectionPage {
await this.page.goto( `/shop` ); await this.page.goto( `/shop` );
} }
async goToProductCatalogAndInsertBlock( async goToProductCatalogAndInsertCollection( collection?: Collections ) {
block: BlockRepresentation = {
name: this.BLOCK_NAME,
}
) {
await this.templateApiUtils.revertTemplate( await this.templateApiUtils.revertTemplate(
'woocommerce/woocommerce//archive-product' 'woocommerce/woocommerce//archive-product'
); );
@ -118,10 +157,10 @@ class ProductCollectionPage {
postId: 'woocommerce/woocommerce//archive-product', postId: 'woocommerce/woocommerce//archive-product',
postType: 'wp_template', postType: 'wp_template',
} ); } );
await this.editorUtils.waitForSiteEditorFinishLoading();
await this.editor.canvas.click( 'body' ); await this.editor.canvas.click( 'body' );
await this.editor.insertBlock( { name: this.BLOCK_NAME } );
await this.editor.insertBlock( block ); await this.chooseCollectionInTemplate( collection );
await this.editor.openDocumentSettingsSidebar(); await this.editor.openDocumentSettingsSidebar();
await this.editor.saveSiteEditorEntities(); await this.editor.saveSiteEditorEntities();
} }
@ -181,6 +220,18 @@ class ProductCollectionPage {
await this.refreshLocators( 'editor' ); 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( async setShowOnlyProductsOnSale(
{ {
onSale, onSale,
@ -246,16 +297,7 @@ class ProductCollectionPage {
await this.refreshLocators( 'editor' ); await this.refreshLocators( 'editor' );
} }
async setDisplaySettings( { async clickDisplaySettings() {
itemsPerPage,
offset,
maxPageToShow,
}: {
itemsPerPage: number;
offset: number;
maxPageToShow: number;
isOnFrontend?: boolean;
} ) {
// Select the block, so that toolbar is visible. // Select the block, so that toolbar is visible.
const block = this.page const block = this.page
.locator( `[data-type="${ this.BLOCK_NAME }"]` ) .locator( `[data-type="${ this.BLOCK_NAME }"]` )
@ -266,7 +308,18 @@ class ProductCollectionPage {
await this.page await this.page
.getByRole( 'button', { name: 'Display settings' } ) .getByRole( 'button', { name: 'Display settings' } )
.click(); .click();
}
async setDisplaySettings( {
itemsPerPage,
offset,
maxPageToShow,
}: {
itemsPerPage: number;
offset: number;
maxPageToShow: number;
isOnFrontend?: boolean;
} ) {
// Set the values. // Set the values.
const displaySettingsContainer = this.page.locator( const displaySettingsContainer = this.page.locator(
'.wc-block-editor-product-collection__display-settings' '.wc-block-editor-product-collection__display-settings'
@ -364,6 +417,10 @@ class ProductCollectionPage {
return this.page.getByTestId( testId ); return this.page.getByTestId( testId );
} }
async getCollectionHeading() {
return this.page.getByRole( 'heading' );
}
/** /**
* Private methods to be used by the class. * Private methods to be used by the class.
*/ */

View File

@ -232,6 +232,17 @@ export class EditorUtils {
return firstBlockIndex < secondBlockIndex; 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( async setLayoutOption(
option: option:
| 'Align Top' | 'Align Top'

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Product Collection: introduce the new flow of adding Product Collection block along with a preconfigured set of Collections: New Arrivals, Top Rated, Best Selling, On Sale, Featured