[Experimental] Product Filters Redesign: Add the Product Filters block (#47294)

* Add Product Filters block

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

* Add the blocks template for the Product Filters block

* Dynamically add the highest product count attribute to the block template

* wip: Add E2E tests for the Product Filters block

* Add E2E tests for the Product Filters block

* Add CSS file to Product Filters block

* Fix css error lint

* Fix lint error

* Fix lint errors

* Remove unnecessary styles for the Product Filters block

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Alexandre Lara 2024-05-15 18:09:48 -03:00 committed by GitHub
parent 8dd87820cf
commit 7abb996017
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 494 additions and 0 deletions

View File

@ -21,6 +21,10 @@
"isPreview": {
"type": "boolean",
"default": false
},
"attributeId": {
"type": "number",
"default": 0
}
},
"example": {

View File

@ -22,6 +22,7 @@ const Edit = ( {
heading: string;
filterType: FilterType;
isPreview: boolean;
attributeId: number | undefined;
} > ) => {
const blockProps = useBlockProps();
@ -96,6 +97,11 @@ const Edit = ( {
remove: true,
},
isPreview: attributes.isPreview,
attributeId:
attributes.filterType === 'attribute-filter' &&
attributes.attributeId
? attributes.attributeId
: undefined,
},
],
] }

View File

@ -0,0 +1,21 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/product-filters",
"version": "1.0.0",
"title": "Product Filters (Beta)",
"description": "Let shoppers filter products displayed on the page.",
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true,
"multiple": false,
"interactivity": true
},
"textdomain": "woocommerce",
"usesContext": [ "postId" ],
"providesContext": {},
"attributes": {},
"viewScript": "wc-product-filters-frontend",
"example": {}
}

View File

@ -0,0 +1,135 @@
/**
* External dependencies
*/
import {
InnerBlocks,
useBlockProps,
useInnerBlocksProps,
} from '@wordpress/block-editor';
import { BlockEditProps, InnerBlockTemplate } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { useCollection } from '@woocommerce/base-context/hooks';
import { AttributeTerm } from '@woocommerce/types';
import { Spinner } from '@wordpress/components';
/**
* Internal dependencies
*/
import type { ProductFiltersBlockAttributes } from './types';
const TEMPLATE: InnerBlockTemplate[] = [
[
'core/heading',
{
level: 3,
style: { typography: { fontSize: '24px' } },
content: __( 'Filters', 'woocommerce' ),
},
],
[
'woocommerce/product-filter',
{
filterType: 'active-filters',
heading: __( 'Active', 'woocommerce' ),
},
],
[
'woocommerce/product-filter',
{
filterType: 'price-filter',
heading: __( 'Price', 'woocommerce' ),
},
],
[
'woocommerce/product-filter',
{
filterType: 'stock-filter',
heading: __( 'Status', 'woocommerce' ),
},
],
[
'woocommerce/product-filter',
{
filterType: 'attribute-filter',
heading: __( 'Attribute', 'woocommerce' ),
attributeId: 0,
},
],
[
'woocommerce/product-filter',
{
filterType: 'rating-filter',
heading: __( 'Rating', 'woocommerce' ),
},
],
];
const addHighestProductCountAttributeToTemplate = (
template: InnerBlockTemplate[],
highestProductCountAttribute: AttributeTerm | null
): InnerBlockTemplate[] => {
if ( highestProductCountAttribute === null ) return template;
return template.map( ( block ) => {
const blockNameIndex = 0;
const blockAttributesIndex = 1;
const blockName = block[ blockNameIndex ];
const blockAttributes = block[ blockAttributesIndex ];
if (
blockName === 'woocommerce/product-filter' &&
blockAttributes?.filterType === 'attribute-filter'
) {
return [
blockName,
{
...blockAttributes,
heading: highestProductCountAttribute.name,
attributeId: highestProductCountAttribute.id,
},
];
}
return block;
} );
};
export const Edit = ( {}: BlockEditProps< ProductFiltersBlockAttributes > ) => {
const blockProps = useBlockProps();
const { results: attributes, isLoading } = useCollection< AttributeTerm >( {
namespace: '/wc/store/v1',
resourceName: 'products/attributes',
} );
const highestProductCountAttribute =
attributes.reduce< AttributeTerm | null >(
( attributeWithMostProducts, attribute ) => {
if ( attributeWithMostProducts === null ) {
return attribute;
}
return attribute.count > attributeWithMostProducts.count
? attribute
: attributeWithMostProducts;
},
null
);
const updatedTemplate = addHighestProductCountAttributeToTemplate(
TEMPLATE,
highestProductCountAttribute
);
if ( isLoading ) {
return <Spinner />;
}
return (
<div { ...blockProps }>
<InnerBlocks templateLock={ false } template={ updatedTemplate } />
</div>
);
};
export const Save = () => {
const blockProps = useBlockProps.save();
const innerBlocksProps = useInnerBlocksProps.save( blockProps );
return <div { ...innerBlocksProps } />;
};

View File

@ -0,0 +1,18 @@
/**
* External dependencies
*/
import { store } from '@woocommerce/interactivity';
export interface ProductFiltersContext {
productId: string;
}
const productFilters = {
state: {},
actions: {},
callbacks: {},
};
store( 'woocommerce/product-filters', productFilters );
export type ProductFilters = typeof productFilters;

View File

@ -0,0 +1,36 @@
const Icon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 3H5C4.4 3 4 3.4 4 4V11C4 11.5 4.4 12 5 12H19C19.5 12 20 11.6 20 11V4C20 3.4 19.6 3 19 3ZM5.5 10.5V10.1L7.3 8.8L8.6 9.6C8.9 9.8 9.3 9.8 9.5 9.5L11 8.1L13.4 10.5H5.5ZM18.5 10.5H15.6L11.6 6.5C11.3 6.2 10.8 6.2 10.5 6.5L8.9 8L7.7 7.2C7.4 7 7.1 7 6.8 7.2L5.5 8.2V4.5H18.5V10.5Z"
fill="currentColor"
/>
<rect
x="4.75"
y="15.5"
width="5"
height="4.5"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
/>
<rect
x="12.25"
y="15.5"
width="5"
height="4.5"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
/>
</svg>
);
export default Icon;

View File

@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { isExperimentalBuild } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import metadata from './block.json';
import { ProductFiltersBlockSettings } from './settings';
if ( isExperimentalBuild() ) {
registerBlockType( metadata, ProductFiltersBlockSettings );
}

View File

@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
import classnames from 'classnames';
/**
* Internal dependencies
*/
export const Save = (): JSX.Element => {
const blockProps = useBlockProps.save( {
className: classnames( 'wc-block-product-filters' ),
} );
const innerBlocksProps = useInnerBlocksProps.save( blockProps );
return <div { ...innerBlocksProps } />;
};

View File

@ -0,0 +1,12 @@
/**
* Internal dependencies
*/
import { Edit } from './edit';
import { Save } from './save';
import icon from './icon';
export const ProductFiltersBlockSettings = {
icon,
edit: Edit,
save: Save,
};

View File

@ -0,0 +1,7 @@
/**
* Internal dependencies
*/
export interface ProductFiltersBlockAttributes {
productId?: string;
}

View File

@ -92,6 +92,9 @@ const blocks = {
'product-filter': {
isExperimental: true,
},
'product-filters': {
isExperimental: true,
},
'product-filter-stock-status': {
isExperimental: true,
customDir: 'product-filter/inner-blocks/stock-filter',

View File

@ -0,0 +1,119 @@
/**
* External dependencies
*/
import { test as base, expect } from '@woocommerce/e2e-playwright-utils';
/**
* Internal dependencies
*/
import { ProductFiltersPage } from './product-filters.page';
const blockData = {
name: 'woocommerce/product-filters',
title: 'Product Filters',
selectors: {
frontend: {},
editor: {
settings: {},
},
},
slug: 'archive-product',
productPage: '/product/hoodie/',
};
const test = base.extend< { pageObject: ProductFiltersPage } >( {
pageObject: async ( { page, editor, frontendUtils, editorUtils }, use ) => {
const pageObject = new ProductFiltersPage( {
page,
editor,
frontendUtils,
editorUtils,
} );
await use( pageObject );
},
} );
test.describe( `${ blockData.name }`, () => {
test.beforeEach( async ( { admin, editorUtils } ) => {
await admin.visitSiteEditor( {
postId: `woocommerce/woocommerce//${ blockData.slug }`,
postType: 'wp_template',
} );
await editorUtils.enterEditMode();
} );
test( 'should be visible and contain correct inner blocks', async ( {
page,
pageObject,
} ) => {
await pageObject.addProductFiltersBlock( { cleanContent: true } );
const block = page
.frameLocator( 'iframe[name="editor-canvas"]' )
.getByLabel( 'Block: Product Filters (Beta)' );
await expect( block ).toBeVisible();
const filtersbBlockHeading = block.getByRole( 'document', {
name: 'Filters',
} );
await expect( filtersbBlockHeading ).toBeVisible();
const activeHeading = block.getByRole( 'document', {
name: 'Active',
} );
const activeFilterBlock = block.getByLabel(
'Block: Product Filter: Active'
);
await expect( activeHeading ).toBeVisible();
await expect( activeFilterBlock ).toBeVisible();
const priceHeading = block.getByRole( 'document', {
name: 'Price',
} );
const priceFilterBlock = block.getByLabel(
'Block: Product Filter: Price'
);
await expect( priceHeading ).toBeVisible();
await expect( priceFilterBlock ).toBeVisible();
const statusHeading = block.getByRole( 'document', {
name: 'Status',
} );
const statusFilterBlock = block.getByLabel(
'Block: Product Filter: Stock'
);
await expect( statusHeading ).toBeVisible();
await expect( statusFilterBlock ).toBeVisible();
const colorHeading = block.getByText( 'Color', {
exact: true,
} );
const colorFilterBlock = block.getByLabel(
'Block: Product Filter: Attribute (Beta)'
);
const expectedColorFilterOptions = [
'Blue',
'Green',
'Gray',
'Red',
'Yellow',
];
const colorFilterOptions = (
await colorFilterBlock.allInnerTexts()
)[ 0 ].split( '\n' );
await expect( colorHeading ).toBeVisible();
await expect( colorFilterBlock ).toBeVisible();
expect( colorFilterOptions ).toEqual(
expect.arrayContaining( expectedColorFilterOptions )
);
const ratingHeading = block.getByRole( 'document', {
name: 'Rating',
} );
const ratingFilterBlock = block.getByLabel(
'Block: Product Filter: Rating (Beta)'
);
await expect( ratingHeading ).toBeVisible();
await expect( ratingFilterBlock ).toBeVisible();
} );
} );

View File

@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { Page } from '@playwright/test';
import { EditorUtils, FrontendUtils } from '@woocommerce/e2e-utils';
import { Editor } from '@wordpress/e2e-test-utils-playwright';
export class ProductFiltersPage {
editor: Editor;
page: Page;
frontendUtils: FrontendUtils;
editorUtils: EditorUtils;
constructor( {
editor,
page,
frontendUtils,
editorUtils,
}: {
editor: Editor;
page: Page;
frontendUtils: FrontendUtils;
editorUtils: EditorUtils;
} ) {
this.editor = editor;
this.page = page;
this.frontendUtils = frontendUtils;
this.editorUtils = editorUtils;
}
async addProductFiltersBlock( { cleanContent = true } ) {
if ( cleanContent ) {
await this.editor.setContent( '' );
}
await this.editor.insertBlock( {
name: 'woocommerce/product-filters',
} );
}
async getBlock( { page }: { page: 'frontend' | 'editor' } ) {
const blockName = 'woocommerce/product-filters';
if ( page === 'frontend' ) {
return (
await this.frontendUtils.getBlockByName( blockName )
).filter( {
has: this.page.locator( ':visible' ),
} );
}
return this.editorUtils.getBlockByName( blockName );
}
}

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Comment: Add the Product Filters block

View File

@ -0,0 +1,46 @@
<?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductFilters class.
*/
class ProductFilters extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-filters';
/**
* Register the context
*
* @return string[]
*/
protected function get_block_type_uses_context() {
return [ 'postId' ];
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
return <<<HTML
<div>Product Filters</div>
HTML;
}
}

View File

@ -299,6 +299,7 @@ final class BlockTypesController {
if ( Package::feature()->is_experimental_build() ) {
$block_types[] = 'ProductFilter';
$block_types[] = 'ProductFilters';
$block_types[] = 'ProductFilterStockStatus';
$block_types[] = 'ProductFilterPrice';
$block_types[] = 'ProductFilterAttribute';