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

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

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