[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:
parent
8dd87820cf
commit
7abb996017
|
@ -21,6 +21,10 @@
|
|||
"isPreview": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"attributeId": {
|
||||
"type": "number",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
] }
|
||||
|
|
|
@ -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": {}
|
||||
}
|
|
@ -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 } />;
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 );
|
||||
}
|
|
@ -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 } />;
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
|
||||
export interface ProductFiltersBlockAttributes {
|
||||
productId?: string;
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -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 );
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: add
|
||||
Comment: Add the Product Filters block
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
Loading…
Reference in New Issue