Getting the Product Collection location/context (#43997)

* Early implementation of getting the Product Collection location/context

* Solve the problem of async fetch in the hook

* Improve typing

* Import core data store instead of hardcoding store name

* Recognise Product Category and Product Tag

* Remove attr property from archive location data

* Unify states naming

* Add TODO entry

* Display the info about the location of Product Collection

* Improve the typing

* Recognise if Product Collection is nested in Single Product block

* Improve cases descriptions and add some defaults to potentially undefined values

* Change the taxonomies sourceData

* Recognise Mini Cart as Cart context

* Recognise attribute as archive contect but no taxonomy

* Refactor the function into single useEffect and clean it up

* Fix typo

* Remove unnecessary import

* Stop rendering the output in Editor (it was for demo purposes)

* Pass location data to Product Template query in Editor

* Replace templateSlugs literal strings with object reference

* Rename parseResponse function to more specific name getIdFromResponse

* Add dpeendency array to useEffect

* Refactor templates detection

* Use full taxonomy names instead of shortcuts

* Write down scenarios to test

* Working scenario

* Change the verification way for more robust

* Add more robust methods to include Single Product block

* Add test Product Collection in Single Product block in a Single Product Template

* Imprvoe the order of veryfing the requests

* Fix linter issues. Although that makes code less readable

* Improve the useGetLocation typing so it's more generic

* Rework the E2E tests regarding location of Product Collection and limit their number

* Bring back necessary eslint-disable

* Remove unused imports

* Uncomment line required for other tests

* Add changelog

* Rename constant from BLOCK_NAME to BLOCK_SLUG as it's a slug

* Add a BLOCK_NAME constant and replace the literal block name usages in E2E tests

* Fix post merge issues

* Fix test after merge

* Adjust the tests to kick off waiting for request before action that triggers them
This commit is contained in:
Karol Manijak 2024-02-12 20:59:40 +01:00 committed by GitHub
parent cc0d7368e9
commit 2750e79224
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 543 additions and 54 deletions

View File

@ -14,6 +14,7 @@
"queryContext",
"displayLayout",
"templateSlug",
"postId",
"queryContextIncludes",
"collection"
],

View File

@ -25,7 +25,7 @@ import type { BlockEditProps, BlockInstance } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { useProductCollectionQueryContext } from './utils';
import { useGetLocation, useProductCollectionQueryContext } from './utils';
import './editor.scss';
const DEFAULT_QUERY_CONTEXT_ATTRIBUTES = [ 'collection', 'id' ];
@ -125,37 +125,42 @@ const ProductContent = withProduct(
}
);
const ProductTemplateEdit = ( {
clientId,
context: {
query: {
perPage,
offset = 0,
order,
orderBy,
search,
exclude,
inherit,
taxQuery,
pages,
...restQueryArgs
const ProductTemplateEdit = (
props: BlockEditProps< {
clientId: string;
} > & {
context: ProductCollectionAttributes;
__unstableLayoutClassNames: string;
}
) => {
const {
clientId,
context: {
query: {
perPage,
offset = 0,
order,
orderBy,
search,
exclude,
inherit,
taxQuery,
pages,
...restQueryArgs
},
queryContext = [ { page: 1 } ],
templateSlug,
displayLayout: { type: layoutType, columns, shrinkColumns } = {
type: 'flex',
columns: 3,
shrinkColumns: false,
},
queryContextIncludes = [],
},
queryContext = [ { page: 1 } ],
templateSlug,
displayLayout: { type: layoutType, columns, shrinkColumns } = {
type: 'flex',
columns: 3,
shrinkColumns: false,
},
queryContextIncludes = [],
},
__unstableLayoutClassNames,
}: BlockEditProps< {
clientId: string;
} > & {
context: ProductCollectionAttributes;
__unstableLayoutClassNames: string;
} ) => {
__unstableLayoutClassNames,
} = props;
const location = useGetLocation( props.context, props.clientId );
const [ { page } ] = queryContext;
const [ activeBlockContextId, setActiveBlockContextId ] =
useState< string >();
@ -242,6 +247,7 @@ const ProductTemplateEdit = ( {
products: getEntityRecords( 'postType', postType, {
...query,
...restQueryArgs,
location,
productCollectionQueryContext,
} ),
blocks: getBlocks( clientId ),
@ -261,6 +267,7 @@ const ProductTemplateEdit = ( {
templateSlug,
taxQuery,
restQueryArgs,
location,
productCollectionQueryContext,
loopShopPerPage,
]

View File

@ -1,8 +1,268 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
import { useMemo } from '@wordpress/element';
import { resolveSelect, useSelect } from '@wordpress/data';
import { useState, useEffect, useMemo } from '@wordpress/element';
import { store as coreStore } from '@wordpress/core-data';
import { store as blockEditorStore } from '@wordpress/block-editor';
type LocationType = 'product' | 'archive' | 'cart' | 'order' | 'generic';
type Context< T > = T & {
templateSlug?: string;
postId?: number;
};
type SetEntityId = (
kind: 'postType' | 'taxonomy',
name: 'product' | 'product_cat' | 'product_tag',
slug: string,
stateSetter: ( entityId: number | null ) => void
) => void;
const templateSlugs = {
singleProduct: 'single-product',
productCategory: 'taxonomy-product_cat',
productTag: 'taxonomy-product_tag',
productAttribute: 'taxonomy-product_attribute',
orderConfirmation: 'order-confirmation',
cart: 'page-cart',
checkout: 'page-checkout',
};
const getIdFromResponse = ( resp?: Record< 'id', number >[] ): number | null =>
resp && resp.length && resp[ 0 ]?.id ? resp[ 0 ].id : null;
const setEntityId: SetEntityId = async ( kind, name, slug, stateSetter ) => {
const response = ( await resolveSelect( coreStore ).getEntityRecords(
kind,
name,
{
_fields: [ 'id' ],
slug,
}
) ) as Record< 'id', number >[];
const entityId = getIdFromResponse( response );
stateSetter( entityId );
};
const prepareGetEntitySlug =
( templateSlug: string ) =>
( entitySlug: string ): string =>
templateSlug.replace( `${ entitySlug }-`, '' );
const prepareIsInSpecificTemplate =
( templateSlug: string ) =>
( entitySlug: string ): boolean =>
templateSlug.includes( entitySlug ) && templateSlug !== entitySlug;
const prepareIsInGenericTemplate =
( templateSlug: string ) =>
( entitySlug: string ): boolean =>
templateSlug === entitySlug;
const createLocationObject = ( type: LocationType, sourceData = {} ) => ( {
type,
sourceData,
} );
type ContextProperties = {
templateSlug: string;
postId?: string;
};
export const useGetLocation = < T, >(
context: Context< T & ContextProperties >,
clientId: string
) => {
const templateSlug = context.templateSlug || '';
const postId = context.postId || null;
const getEntitySlug = prepareGetEntitySlug( templateSlug );
const isInSpecificTemplate = prepareIsInSpecificTemplate( templateSlug );
// Detect Specific Templates
const isInSpecificProductTemplate = isInSpecificTemplate(
templateSlugs.singleProduct
);
const isInSpecificCategoryTemplate = isInSpecificTemplate(
templateSlugs.productCategory
);
const isInSpecificTagTemplate = isInSpecificTemplate(
templateSlugs.productTag
);
const [ productId, setProductId ] = useState< number | null >( null );
const [ categoryId, setCategoryId ] = useState< number | null >( null );
const [ tagId, setTagId ] = useState< number | null >( null );
useEffect( () => {
if ( isInSpecificProductTemplate ) {
const slug = getEntitySlug( templateSlugs.singleProduct );
setEntityId( 'postType', 'product', slug, setProductId );
}
if ( isInSpecificCategoryTemplate ) {
const slug = getEntitySlug( templateSlugs.productCategory );
setEntityId( 'taxonomy', 'product_cat', slug, setCategoryId );
}
if ( isInSpecificTagTemplate ) {
const slug = getEntitySlug( templateSlugs.productTag );
setEntityId( 'taxonomy', 'product_tag', slug, setTagId );
}
}, [
isInSpecificProductTemplate,
isInSpecificCategoryTemplate,
isInSpecificTagTemplate,
getEntitySlug,
] );
const { isInSingleProductBlock, isInMiniCartBlock } = useSelect(
( select ) => ( {
isInSingleProductBlock:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this selector exist yet
select( blockEditorStore ).getBlockParentsByBlockName(
clientId,
'woocommerce/single-product'
).length > 0,
isInMiniCartBlock:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this selector exist yet
select( blockEditorStore ).getBlockParentsByBlockName(
clientId,
'woocommerce/mini-cart-contents'
).length > 0,
} ),
[ clientId ]
);
/**
* Case 1.1: SPECIFIC PRODUCT
* Single Product block - take product ID from context
*/
if ( isInSingleProductBlock ) {
return createLocationObject( 'product', { productId: postId } );
}
/**
* Case 1.2: SPECIFIC PRODUCT
* Specific Single Product template - take product ID from taxononmy
*/
if ( isInSpecificProductTemplate ) {
return createLocationObject( 'product', { productId } );
}
const isInGenericTemplate = prepareIsInGenericTemplate( templateSlug );
/**
* Case 1.3: GENERIC PRODUCT
* Generic Single Product template
*/
const isInSingleProductTemplate = isInGenericTemplate(
templateSlugs.singleProduct
);
if ( isInSingleProductTemplate ) {
return createLocationObject( 'product', { productId: null } );
}
/**
* Case 2.1: SPECIFIC TAXONOMY
* Specific Category template - take category ID from
*/
if ( isInSpecificCategoryTemplate ) {
return createLocationObject( 'archive', {
taxonomy: 'product_cat',
termId: categoryId,
} );
}
/**
* Case 2.2: SPECIFIC TAXONOMY
* Specific Tag template
*/
if ( isInSpecificTagTemplate ) {
return createLocationObject( 'archive', {
taxonomy: 'product_tag',
termId: tagId,
} );
}
/**
* Case 2.3: GENERIC TAXONOMY
* Generic Taxonomy template
*/
const isInProductsByCategoryTemplate = isInGenericTemplate(
templateSlugs.productCategory
);
if ( isInProductsByCategoryTemplate ) {
return createLocationObject( 'archive', {
taxonomy: 'product_cat',
termId: null,
} );
}
const isInProductsByTagTemplate = isInGenericTemplate(
templateSlugs.productTag
);
if ( isInProductsByTagTemplate ) {
return createLocationObject( 'archive', {
taxonomy: 'product_tag',
termId: null,
} );
}
const isInProductsByAttributeTemplate = isInGenericTemplate(
templateSlugs.productAttribute
);
if ( isInProductsByAttributeTemplate ) {
return createLocationObject( 'archive', {
taxonomy: null,
termId: null,
} );
}
/**
* Case 3: GENERIC CART
* Cart/Checkout templates or Mini Cart
*/
const isInCartContext =
templateSlug === templateSlugs.cart ||
templateSlug === templateSlugs.checkout ||
isInMiniCartBlock;
if ( isInCartContext ) {
return createLocationObject( 'cart' );
}
/**
* Case 4: GENERIC ORDER
* Order Confirmation template
*/
const isInOrderTemplate = isInGenericTemplate(
templateSlugs.orderConfirmation
);
if ( isInOrderTemplate ) {
return createLocationObject( 'order' );
}
/**
* Case 5: GENERIC
* All other cases
*/
return createLocationObject( 'generic' );
};
/**
* In Product Collection block, queryContextIncludes attribute contains

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import { test as base, expect } from '@woocommerce/e2e-playwright-utils';
import type { Request } from '@playwright/test';
/**
* Internal dependencies
@ -698,16 +699,11 @@ test.describe( 'Product Collection', () => {
} );
test.describe( 'With other blocks', () => {
test( 'In Single Product block', async ( {
admin,
editorUtils,
pageObject,
} ) => {
test( 'In Single Product block', async ( { admin, pageObject } ) => {
await admin.createNewPost();
await editorUtils.closeWelcomeGuideModal();
await pageObject.insertProductCollectionInSingleProductBlock(
'featured'
);
await pageObject.insertProductCollectionInSingleProductBlock();
await pageObject.chooseCollectionInPost( 'featured' );
await pageObject.refreshLocators( 'editor' );
const featuredProducts = [
'Cap',
@ -734,6 +730,189 @@ test.describe( 'Product Collection', () => {
} );
} );
test.describe( 'Location is recognised', () => {
const filterRequest = ( request: Request ) => {
const url = request.url();
return (
url.includes( 'wp/v2/product' ) &&
url.includes( 'isProductCollectionBlock=true' )
);
};
const filterProductRequest = ( request: Request ) => {
const url = request.url();
const searchParams = new URLSearchParams( request.url() );
return (
url.includes( 'wp/v2/product' ) &&
searchParams.get( 'isProductCollectionBlock' ) === 'true' &&
!! searchParams.get( `location[sourceData][productId]` )
);
};
const getLocationDetailsFromRequest = (
request: Request,
locationType?: string
) => {
const searchParams = new URLSearchParams( request.url() );
if ( locationType === 'product' ) {
return {
type: searchParams.get( 'location[type]' ),
productId: searchParams.get(
`location[sourceData][productId]`
),
};
}
if ( locationType === 'archive' ) {
return {
type: searchParams.get( 'location[type]' ),
taxonomy: searchParams.get(
`location[sourceData][taxonomy]`
),
termId: searchParams.get( `location[sourceData][termId]` ),
};
}
return {
type: searchParams.get( 'location[type]' ),
sourceData: searchParams.get( `location[sourceData]` ),
};
};
test( 'as product in specific Single Product template', async ( {
page,
pageObject,
editorUtils,
} ) => {
const productName = 'Cap';
const productSlug = 'cap';
await editorUtils.openSpecificProductTemplate(
productName,
productSlug
);
await editorUtils.insertBlockUsingGlobalInserter(
pageObject.BLOCK_NAME
);
const locationReuqestPromise =
page.waitForRequest( filterProductRequest );
await pageObject.chooseCollectionInTemplate( 'featured' );
const locationRequest = await locationReuqestPromise;
const { type, productId } = getLocationDetailsFromRequest(
locationRequest,
'product'
);
expect( type ).toBe( 'product' );
expect( productId ).toBeTruthy();
} );
test( 'as category in Products by Category template', async ( {
admin,
editorUtils,
pageObject,
page,
} ) => {
await admin.visitSiteEditor( {
postId: `woocommerce/woocommerce//taxonomy-product_cat`,
postType: 'wp_template',
} );
await editorUtils.enterEditMode();
await editorUtils.insertBlockUsingGlobalInserter(
pageObject.BLOCK_NAME
);
const locationReuqestPromise = page.waitForRequest( filterRequest );
await pageObject.chooseCollectionInTemplate( 'featured' );
const locationRequest = await locationReuqestPromise;
const { type, taxonomy, termId } = getLocationDetailsFromRequest(
locationRequest,
'archive'
);
expect( type ).toBe( 'archive' );
expect( taxonomy ).toBe( 'product_cat' );
// Field is sent as a null but browser converts it to empty string
expect( termId ).toBe( '' );
} );
test( 'as tag in Products by Tag template', async ( {
admin,
editorUtils,
pageObject,
page,
} ) => {
await admin.visitSiteEditor( {
postId: `woocommerce/woocommerce//taxonomy-product_tag`,
postType: 'wp_template',
} );
await editorUtils.enterEditMode();
await editorUtils.insertBlockUsingGlobalInserter(
pageObject.BLOCK_NAME
);
const locationReuqestPromise = page.waitForRequest( filterRequest );
await pageObject.chooseCollectionInTemplate( 'featured' );
const locationRequest = await locationReuqestPromise;
const { type, taxonomy, termId } = getLocationDetailsFromRequest(
locationRequest,
'archive'
);
expect( type ).toBe( 'archive' );
expect( taxonomy ).toBe( 'product_tag' );
// Field is sent as a null but browser converts it to empty string
expect( termId ).toBe( '' );
} );
test( 'as generic in post', async ( {
admin,
editorUtils,
pageObject,
page,
} ) => {
await admin.createNewPost();
await editorUtils.insertBlockUsingGlobalInserter(
pageObject.BLOCK_NAME
);
const locationReuqestPromise = page.waitForRequest( filterRequest );
await pageObject.chooseCollectionInPost( 'featured' );
const locationRequest = await locationReuqestPromise;
const { type, sourceData } =
getLocationDetailsFromRequest( locationRequest );
expect( type ).toBe( 'generic' );
// Field is not sent at all. URLSearchParams get method returns a null
// if field is not available.
expect( sourceData ).toBe( null );
} );
test( 'as product in Single Product block in post', async ( {
admin,
pageObject,
page,
} ) => {
await admin.createNewPost();
await pageObject.insertProductCollectionInSingleProductBlock();
const locationReuqestPromise =
page.waitForRequest( filterProductRequest );
await pageObject.chooseCollectionInPost( 'featured' );
const locationRequest = await locationReuqestPromise;
const { type, productId } = getLocationDetailsFromRequest(
locationRequest,
'product'
);
expect( type ).toBe( 'product' );
expect( productId ).toBeTruthy();
} );
} );
test.describe( 'Query Context in Editor', () => {
test( 'Product Catalog: Sends only ID in Query Context', async ( {
pageObject,

View File

@ -69,12 +69,13 @@ const collectionToButtonNameMap = {
};
class ProductCollectionPage {
private BLOCK_NAME = 'woocommerce/product-collection';
private BLOCK_SLUG = 'woocommerce/product-collection';
private page: Page;
private admin: Admin;
private editor: Editor;
private templateApiUtils: TemplateApiUtils;
private editorUtils: EditorUtils;
BLOCK_NAME = 'Product Collection (Beta)';
productTemplate!: Locator;
products!: Locator;
productImages!: Locator;
@ -127,7 +128,7 @@ class ProductCollectionPage {
async createNewPostAndInsertBlock( collection?: Collections ) {
await this.admin.createNewPost( { legacyCanvas: true } );
await this.editor.insertBlock( {
name: this.BLOCK_NAME,
name: this.BLOCK_SLUG,
} );
await this.chooseCollectionInPost( collection );
await this.refreshLocators( 'editor' );
@ -142,7 +143,7 @@ class ProductCollectionPage {
await this.admin.createNewPost();
await this.editorUtils.closeWelcomeGuideModal();
await this.editor.insertBlock( {
name: this.BLOCK_NAME,
name: this.BLOCK_SLUG,
} );
await this.chooseCollectionInPost( collection );
@ -181,7 +182,7 @@ class ProductCollectionPage {
await this.editorUtils.enterEditMode();
await this.editorUtils.replaceBlockByBlockName(
'core/query',
this.BLOCK_NAME
this.BLOCK_SLUG
);
await this.chooseCollectionInTemplate( collection );
await this.editor.saveSiteEditorEntities();
@ -202,7 +203,7 @@ class ProductCollectionPage {
} );
await this.editorUtils.waitForSiteEditorFinishLoading();
await this.editor.canvas.click( 'body' );
await this.editor.insertBlock( { name: this.BLOCK_NAME } );
await this.editor.insertBlock( { name: this.BLOCK_SLUG } );
await this.chooseCollectionInTemplate( collection );
await this.editor.openDocumentSettingsSidebar();
await this.editor.saveSiteEditorEntities();
@ -407,7 +408,7 @@ class ProductCollectionPage {
async clickDisplaySettings() {
// Select the block, so that toolbar is visible.
const block = this.page
.locator( `[data-type="${ this.BLOCK_NAME }"]` )
.locator( `[data-type="${ this.BLOCK_SLUG }"]` )
.first();
await this.editor.selectBlocks( block );
@ -511,9 +512,7 @@ class ProductCollectionPage {
await this.page.setViewportSize( { width, height } );
}
async insertProductCollectionInSingleProductBlock(
collection: Collections
) {
async insertProductCollectionInSingleProductBlock() {
this.insertSingleProductBlock();
const siblingBlock = await this.editorUtils.getBlockByName(
@ -526,12 +525,10 @@ class ProductCollectionPage {
await this.editor.selectBlocks( siblingBlock );
await this.editorUtils.insertBlock(
{ name: this.BLOCK_NAME },
{ name: this.BLOCK_SLUG },
undefined,
parentClientId
);
await this.chooseCollectionInPost( collection );
await this.refreshLocators( 'editor' );
}
/**

View File

@ -454,4 +454,45 @@ export class EditorUtils {
.first()
.click();
}
/**
* Opens a specific Single Product template.
*/
async openSpecificProductTemplate(
productName: string,
productSlug: string,
createIfDoesntExist = true
) {
await this.page.goto( '/wp-admin/site-editor.php' );
await this.page.getByRole( 'button', { name: 'Templates' } ).click();
const templateButton = this.page.getByRole( 'button', {
name: `Product: ${ productName }`,
} );
// Template can be created only once. Go to template if exists,
// otherwise create one.
if ( await templateButton.isVisible() ) {
await templateButton.click();
await this.enterEditMode();
} else if ( createIfDoesntExist ) {
await this.page
.getByRole( 'button', { name: 'Add New Template' } )
.click();
await this.page
.getByRole( 'button', { name: 'Single Item: Product' } )
.click();
await this.page
.getByRole( 'option', {
name: `${ productName } http://localhost:8889/product/${ productSlug }/`,
} )
.click();
await this.page
.getByRole( 'button', {
name: 'Skip',
} )
.click();
}
await this.closeWelcomeGuideModal();
}
}

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Product Collection: recognise the location of block in Editor and pass it with the request