[Experimental] Attribute Filter: Update Inspector Control Settings (#49751)

* add: new inspector controls

* update: cleanup the edit component

* chore: remove unused old inspector component

* tweak: add formatting to help text

as Jarek said:

> Here’s the copy. Please mind the formatting.

* add: default attribute id that closest to 30

* update: pass the whole attribute object

* update: use default attribute term in Product Filters template

* add: style settings

* add: sorting and show empty behavior in the editor preview

* tweak: use hideEmpty to align with existing settings

* chore: changelog

* test: add basic e2e tests

* chore: restore previous default value

* add: attribute help text and border support

* chore: lint

* fix: add missing context
This commit is contained in:
Tung Du 2024-07-25 09:13:56 +07:00 committed by GitHub
parent 6000a1e83c
commit 234f5d7e49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 594 additions and 470 deletions

View File

@ -14,7 +14,45 @@
"inserter": false, "inserter": false,
"color": { "color": {
"text": true, "text": true,
"background": false "background": false,
"__experimentalDefaultControls": {
"text": false
}
},
"typography": {
"fontSize": true,
"lineHeight": true,
"__experimentalFontWeight": true,
"__experimentalFontFamily": true,
"__experimentalFontStyle": true,
"__experimentalTextTransform": true,
"__experimentalTextDecoration": true,
"__experimentalLetterSpacing": true,
"__experimentalDefaultControls": {
"fontSize": false
}
},
"spacing": {
"margin": true,
"padding": true,
"blockGap": true,
"__experimentalDefaultControls": {
"margin": false,
"padding": false,
"blockGap": false
}
},
"__experimentalBorder": {
"color": true,
"radius": true,
"style": true,
"width": true,
"__experimentalDefaultControls": {
"color": false,
"radius": false,
"style": false,
"width": false
}
} }
}, },
"usesContext": [ "query", "queryId" ], "usesContext": [ "query", "queryId" ],
@ -42,6 +80,18 @@
"isPreview": { "isPreview": {
"type": "boolean", "type": "boolean",
"default": false "default": false
},
"sortOrder": {
"type": "string",
"default": "count-desc"
},
"hideEmpty": {
"type": "boolean",
"default": true
},
"clearButton": {
"type": "boolean",
"default":true
} }
} }
} }

View File

@ -1,137 +0,0 @@
/**
* External dependencies
*/
import { __, _x } from '@wordpress/i18n';
import { InspectorControls } from '@wordpress/block-editor';
import {
PanelBody,
ToggleControl,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { AttributeSelectControls } from './attribute-select-controls';
import { EditProps } from '../types';
export const Inspector = ( { attributes, setAttributes }: EditProps ) => {
const { attributeId, showCounts, queryType, displayStyle, selectType } =
attributes;
return (
<InspectorControls key="inspector">
<PanelBody title={ __( 'Display Settings', 'woocommerce' ) }>
<ToggleControl
label={ __( 'Display product count', 'woocommerce' ) }
checked={ showCounts }
onChange={ () =>
setAttributes( {
showCounts: ! showCounts,
} )
}
/>
<ToggleGroupControl
label={ __(
'Allow selecting multiple options?',
'woocommerce'
) }
value={ selectType || 'multiple' }
onChange={ ( value: string ) =>
setAttributes( {
selectType: value,
} )
}
className="wc-block-attribute-filter__multiple-toggle"
>
<ToggleGroupControlOption
value="multiple"
label={ _x(
'Multiple',
'Number of filters',
'woocommerce'
) }
/>
<ToggleGroupControlOption
value="single"
label={ _x(
'Single',
'Number of filters',
'woocommerce'
) }
/>
</ToggleGroupControl>
{ selectType === 'multiple' && (
<ToggleGroupControl
label={ __( 'Filter Conditions', 'woocommerce' ) }
help={
queryType === 'and'
? __(
'Choose to return filter results for all of the attributes selected.',
'woocommerce'
)
: __(
'Choose to return filter results for any of the attributes selected.',
'woocommerce'
)
}
value={ queryType }
onChange={ ( value: string ) =>
setAttributes( {
queryType: value,
} )
}
className="wc-block-attribute-filter__conditions-toggle"
>
<ToggleGroupControlOption
value="and"
label={ __( 'All', 'woocommerce' ) }
/>
<ToggleGroupControlOption
value="or"
label={ __( 'Any', 'woocommerce' ) }
/>
</ToggleGroupControl>
) }
<ToggleGroupControl
label={ __( 'Display Style', 'woocommerce' ) }
value={ displayStyle }
onChange={ ( value: string ) =>
setAttributes( {
displayStyle: value,
} )
}
className="wc-block-attribute-filter__display-toggle"
>
<ToggleGroupControlOption
value="list"
label={ __( 'List', 'woocommerce' ) }
/>
<ToggleGroupControlOption
value="dropdown"
label={ __( 'Dropdown', 'woocommerce' ) }
/>
</ToggleGroupControl>
</PanelBody>
<PanelBody
title={ __( 'Content Settings', 'woocommerce' ) }
initialOpen={ false }
>
<AttributeSelectControls
isCompact={ true }
attributeId={ attributeId }
setAttributeId={ ( id: number ) => {
setAttributes( {
attributeId: id,
} );
} }
/>
</PanelBody>
</InspectorControls>
);
};

View File

@ -0,0 +1,160 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
import { AttributeSetting } from '@woocommerce/types';
import { InspectorControls } from '@wordpress/block-editor';
import { createInterpolateElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
ComboboxControl,
PanelBody,
SelectControl,
ToggleControl,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { sortOrderOptions } from '../constants';
import { BlockAttributes, EditProps } from '../types';
const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
export const Inspector = ( { attributes, setAttributes }: EditProps ) => {
const {
attributeId,
sortOrder,
queryType,
displayStyle,
showCounts,
hideEmpty,
clearButton,
} = attributes;
return (
<>
<InspectorControls key="inspector">
<PanelBody title={ __( 'Attribute', 'woocommerce' ) }>
<ComboboxControl
options={ ATTRIBUTES.map( ( item ) => ( {
value: item.attribute_id,
label: item.attribute_label,
} ) ) }
value={ attributeId + '' }
onChange={ ( value ) =>
setAttributes( {
attributeId: parseInt( value || '', 10 ),
} )
}
help={ __(
'Choose the attribute to show in this filter.',
'woocommerce'
) }
/>
</PanelBody>
<PanelBody title={ __( 'Settings', 'woocommerce' ) }>
<SelectControl
label={ __( 'Sort order', 'woocommerce' ) }
value={ sortOrder }
options={ [
{
value: '',
label: __( 'Select an option', 'woocommerce' ),
disabled: true,
},
...sortOrderOptions,
] }
onChange={ ( value ) =>
setAttributes( { sortOrder: value } )
}
help={ __(
'Determine the order of filter options.',
'woocommerce'
) }
/>
<ToggleGroupControl
label={ __( 'Logic', 'woocommerce' ) }
value={ queryType }
onChange={ ( value: BlockAttributes[ 'queryType' ] ) =>
setAttributes( { queryType: value } )
}
style={ { width: '100%' } }
help={
queryType === 'and'
? createInterpolateElement(
__(
'Show results for <b>all</b> selected attributes. Displayed products must contain <b>all of them</b> to appear in the results.',
'woocommerce'
),
{
b: <strong />,
}
)
: __(
'Show results for any of the attributes selected (displayed products dont have to have them all).',
'woocommerce'
)
}
>
<ToggleGroupControlOption
label={ __( 'Any', 'woocommerce' ) }
value="or"
/>
<ToggleGroupControlOption
label={ __( 'All', 'woocommerce' ) }
value="and"
/>
</ToggleGroupControl>
</PanelBody>
</InspectorControls>
<InspectorControls group="styles">
<PanelBody title={ __( 'Display', 'woocommerce' ) }>
<ToggleGroupControl
value={ displayStyle }
onChange={ (
value: BlockAttributes[ 'displayStyle' ]
) => setAttributes( { displayStyle: value } ) }
style={ { width: '100%' } }
>
<ToggleGroupControlOption
label={ __( 'List', 'woocommerce' ) }
value="list"
/>
<ToggleGroupControlOption
label={ __( 'Chips', 'woocommerce' ) }
value="chips"
/>
</ToggleGroupControl>
<ToggleControl
label={ __( 'Product counts', 'woocommerce' ) }
checked={ showCounts }
onChange={ ( value ) =>
setAttributes( { showCounts: value } )
}
/>
<ToggleControl
label={ __( 'Empty filter options', 'woocommerce' ) }
checked={ ! hideEmpty }
onChange={ ( value ) =>
setAttributes( { hideEmpty: ! value } )
}
/>
<ToggleControl
label={ __( 'Clear button', 'woocommerce' ) }
checked={ clearButton }
onChange={ ( value ) =>
setAttributes( { clearButton: value } )
}
/>
</PanelBody>
</InspectorControls>
</>
);
};

View File

@ -1,7 +1,12 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export const attributeOptionsPreview = [ export const attributeOptionsPreview = [
{ {
id: 23, id: 23,
name: 'Blue', name: __( 'Blue', 'woocommerce' ),
slug: 'blue', slug: 'blue',
attr_slug: 'blue', attr_slug: 'blue',
description: '', description: '',
@ -10,7 +15,7 @@ export const attributeOptionsPreview = [
}, },
{ {
id: 29, id: 29,
name: 'Gray', name: __( 'Gray', 'woocommerce' ),
slug: 'gray', slug: 'gray',
attr_slug: 'gray', attr_slug: 'gray',
description: '', description: '',
@ -19,7 +24,7 @@ export const attributeOptionsPreview = [
}, },
{ {
id: 24, id: 24,
name: 'Green', name: __( 'Green', 'woocommerce' ),
slug: 'green', slug: 'green',
attr_slug: 'green', attr_slug: 'green',
description: '', description: '',
@ -28,7 +33,7 @@ export const attributeOptionsPreview = [
}, },
{ {
id: 25, id: 25,
name: 'Red', name: __( 'Red', 'woocommerce' ),
slug: 'red', slug: 'red',
attr_slug: 'red', attr_slug: 'red',
description: '', description: '',
@ -37,7 +42,7 @@ export const attributeOptionsPreview = [
}, },
{ {
id: 30, id: 30,
name: 'Yellow', name: __( 'Yellow', 'woocommerce' ),
slug: 'yellow', slug: 'yellow',
attr_slug: 'yellow', attr_slug: 'yellow',
description: '', description: '',
@ -45,3 +50,17 @@ export const attributeOptionsPreview = [
count: 1, count: 1,
}, },
]; ];
export const sortOrders = {
'name-asc': __( 'Name, A to Z', 'woocommerce' ),
'name-desc': __( 'Name, Z to A', 'woocommerce' ),
'count-desc': __( 'Most results first', 'woocommerce' ),
'count-asc': __( 'Least results first', 'woocommerce' ),
};
export const sortOrderOptions = Object.entries( sortOrders ).map(
( [ value, label ] ) => ( {
label,
value,
} )
);

View File

@ -2,8 +2,8 @@
* External dependencies * External dependencies
*/ */
import { __, sprintf } from '@wordpress/i18n'; import { __, sprintf } from '@wordpress/i18n';
import { useCallback, useEffect, useState } from '@wordpress/element'; import { useEffect, useState } from '@wordpress/element';
import { BlockControls, useBlockProps } from '@wordpress/block-editor'; import { useBlockProps } from '@wordpress/block-editor';
import { getSetting } from '@woocommerce/settings'; import { getSetting } from '@woocommerce/settings';
import { import {
useCollection, useCollection,
@ -14,26 +14,16 @@ import {
AttributeTerm, AttributeTerm,
objectHasProp, objectHasProp,
} from '@woocommerce/types'; } from '@woocommerce/types';
import { import { Disabled, withSpokenMessages, Notice } from '@wordpress/components';
Disabled,
Button,
ToolbarGroup,
withSpokenMessages,
Notice,
} from '@wordpress/components';
import { dispatch, useSelect } from '@wordpress/data'; import { dispatch, useSelect } from '@wordpress/data';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { EditProps, isAttributeCounts } from './types'; import { EditProps, isAttributeCounts } from './types';
import { import { NoAttributesPlaceholder } from './components/placeholder';
NoAttributesPlaceholder,
AttributesPlaceholder,
} from './components/placeholder';
import { AttributeSelectControls } from './components/attribute-select-controls';
import { getAttributeFromId } from './utils'; import { getAttributeFromId } from './utils';
import { Inspector } from './components/inspector-controls'; import { Inspector } from './components/inspector';
import { AttributeCheckboxList } from './components/attribute-checkbox-list'; import { AttributeCheckboxList } from './components/attribute-checkbox-list';
import { AttributeDropdown } from './components/attribute-dropdown'; import { AttributeDropdown } from './components/attribute-dropdown';
import { attributeOptionsPreview } from './constants'; import { attributeOptionsPreview } from './constants';
@ -41,84 +31,21 @@ import './style.scss';
const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] ); const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
const Toolbar = ( {
onClick,
isEditing,
}: {
onClick: () => void;
isEditing: boolean;
} ) => (
<BlockControls>
<ToolbarGroup
controls={ [
{
icon: 'edit',
title: __( 'Edit', 'woocommerce' ),
onClick,
isActive: isEditing,
},
] }
/>
</BlockControls>
);
const Wrapper = ( {
children,
onClickToolbarEdit,
isEditing,
blockProps,
}: {
children: React.ReactNode;
onClickToolbarEdit: () => void;
isEditing: boolean;
blockProps: object;
} ) => (
<div { ...blockProps }>
<Toolbar onClick={ onClickToolbarEdit } isEditing={ isEditing } />
{ children }
</div>
);
const AttributeSelectPlaceholder = ( {
attributeId,
setAttributeId,
onClickDone,
}: {
attributeId: number;
setAttributeId: ( id: number ) => void;
onClickDone: () => void;
} ) => (
<AttributesPlaceholder>
<div className="wc-block-attribute-filter__selection">
<AttributeSelectControls
isCompact={ false }
attributeId={ attributeId }
setAttributeId={ setAttributeId }
/>
<Button variant="primary" onClick={ onClickDone }>
{ __( 'Done', 'woocommerce' ) }
</Button>
</div>
</AttributesPlaceholder>
);
const Edit = ( props: EditProps ) => { const Edit = ( props: EditProps ) => {
const { const { attributes: blockAttributes, clientId } = props;
attributes: blockAttributes,
setAttributes,
debouncedSpeak,
clientId,
} = props;
const { attributeId, queryType, isPreview, displayStyle, showCounts } = const {
blockAttributes; attributeId,
queryType,
isPreview,
displayStyle,
showCounts,
sortOrder,
hideEmpty,
} = blockAttributes;
const attributeObject = getAttributeFromId( attributeId ); const attributeObject = getAttributeFromId( attributeId );
const [ isEditing, setIsEditing ] = useState(
! attributeId && ! isPreview
);
const [ attributeOptions, setAttributeOptions ] = useState< const [ attributeOptions, setAttributeOptions ] = useState<
AttributeTerm[] AttributeTerm[]
>( [] ); >( [] );
@ -130,7 +57,7 @@ const Edit = ( props: EditProps ) => {
resourceName: 'products/attributes/terms', resourceName: 'products/attributes/terms',
resourceValues: [ attributeObject?.id || 0 ], resourceValues: [ attributeObject?.id || 0 ],
shouldSelect: blockAttributes.attributeId > 0, shouldSelect: blockAttributes.attributeId > 0,
query: { orderby: 'menu_order' }, query: { orderby: 'menu_order', hide_empty: hideEmpty },
} ); } );
const { results: filteredCounts } = useCollectionData( { const { results: filteredCounts } = useCollectionData( {
@ -183,8 +110,6 @@ const Edit = ( props: EditProps ) => {
[ clientId ] [ clientId ]
); );
const blockProps = useBlockProps();
useEffect( () => { useEffect( () => {
const termIdHasProducts = const termIdHasProducts =
objectHasProp( filteredCounts, 'attribute_counts' ) && objectHasProp( filteredCounts, 'attribute_counts' ) &&
@ -192,14 +117,31 @@ const Edit = ( props: EditProps ) => {
? filteredCounts.attribute_counts.map( ( term ) => term.term ) ? filteredCounts.attribute_counts.map( ( term ) => term.term )
: []; : [];
if ( termIdHasProducts.length === 0 ) return setAttributeOptions( [] ); if ( termIdHasProducts.length === 0 && hideEmpty )
return setAttributeOptions( [] );
setAttributeOptions( setAttributeOptions(
attributeTerms.filter( ( term ) => { attributeTerms
return termIdHasProducts.includes( term.id ); .filter( ( term ) => {
} ) if ( hideEmpty )
return termIdHasProducts.includes( term.id );
return true;
} )
.sort( ( a, b ) => {
switch ( sortOrder ) {
case 'name-asc':
return a.name > b.name ? 1 : -1;
case 'name-desc':
return a.name < b.name ? 1 : -1;
case 'count-asc':
return a.count > b.count ? 1 : -1;
case 'count-desc':
default:
return a.count < b.count ? 1 : -1;
}
} )
); );
}, [ attributeTerms, filteredCounts ] ); }, [ attributeTerms, filteredCounts, sortOrder, hideEmpty ] );
useEffect( () => { useEffect( () => {
if ( productFilterWrapperBlockId ) { if ( productFilterWrapperBlockId ) {
@ -231,36 +173,16 @@ const Edit = ( props: EditProps ) => {
updateBlockAttributes, updateBlockAttributes,
] ); ] );
const onClickDone = useCallback( () => { const Wrapper = ( { children }: { children: React.ReactNode } ) => (
setIsEditing( false ); <div { ...useBlockProps() }>
debouncedSpeak( <Inspector { ...props } />
__( { children }
'Now displaying a preview of the Filter Products by Attribute block.', </div>
'woocommerce'
)
);
}, [ setIsEditing ] );
const setAttributeId = useCallback(
( id ) => {
setAttributes( {
attributeId: id,
} );
},
[ setAttributes ]
); );
const toggleEditing = useCallback( () => {
setIsEditing( ! isEditing );
}, [ isEditing ] );
if ( isPreview ) { if ( isPreview ) {
return ( return (
<Wrapper <Wrapper>
onClickToolbarEdit={ toggleEditing }
isEditing={ isEditing }
blockProps={ blockProps }
>
<Disabled> <Disabled>
<AttributeCheckboxList <AttributeCheckboxList
showCounts={ showCounts } showCounts={ showCounts }
@ -274,37 +196,14 @@ const Edit = ( props: EditProps ) => {
// Block rendering starts. // Block rendering starts.
if ( Object.keys( ATTRIBUTES ).length === 0 ) if ( Object.keys( ATTRIBUTES ).length === 0 )
return ( return (
<Wrapper <Wrapper>
onClickToolbarEdit={ toggleEditing }
isEditing={ isEditing }
blockProps={ blockProps }
>
<NoAttributesPlaceholder /> <NoAttributesPlaceholder />
</Wrapper> </Wrapper>
); );
if ( isEditing )
return (
<Wrapper
onClickToolbarEdit={ toggleEditing }
isEditing={ isEditing }
blockProps={ blockProps }
>
<AttributeSelectPlaceholder
onClickDone={ onClickDone }
attributeId={ attributeId }
setAttributeId={ setAttributeId }
/>
</Wrapper>
);
if ( ! attributeId || ! attributeObject ) if ( ! attributeId || ! attributeObject )
return ( return (
<Wrapper <Wrapper>
onClickToolbarEdit={ toggleEditing }
isEditing={ isEditing }
blockProps={ blockProps }
>
<Notice status="warning" isDismissible={ false }> <Notice status="warning" isDismissible={ false }>
<p> <p>
{ __( { __(
@ -318,11 +217,7 @@ const Edit = ( props: EditProps ) => {
if ( attributeOptions.length === 0 ) if ( attributeOptions.length === 0 )
return ( return (
<Wrapper <Wrapper>
onClickToolbarEdit={ toggleEditing }
isEditing={ isEditing }
blockProps={ blockProps }
>
<Notice status="warning" isDismissible={ false }> <Notice status="warning" isDismissible={ false }>
<p> <p>
{ __( { __(
@ -335,12 +230,7 @@ const Edit = ( props: EditProps ) => {
); );
return ( return (
<Wrapper <Wrapper>
onClickToolbarEdit={ toggleEditing }
isEditing={ isEditing }
blockProps={ blockProps }
>
<Inspector { ...props } />
<Disabled> <Disabled>
{ displayStyle === 'dropdown' ? ( { displayStyle === 'dropdown' ? (
<AttributeDropdown <AttributeDropdown

View File

@ -1,20 +1,33 @@
/** /**
* External dependencies * External dependencies
*/ */
import { registerBlockType } from '@wordpress/blocks';
import { isExperimentalBlocksEnabled } from '@woocommerce/block-settings'; import { isExperimentalBlocksEnabled } from '@woocommerce/block-settings';
import { productFilterOptions } from '@woocommerce/icons'; import { productFilterOptions } from '@woocommerce/icons';
import { getSetting } from '@woocommerce/settings';
import { registerBlockType } from '@wordpress/blocks';
import { AttributeSetting } from '@woocommerce/types';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import './style.scss';
import metadata from './block.json'; import metadata from './block.json';
import Edit from './edit'; import Edit from './edit';
import './style.scss';
if ( isExperimentalBlocksEnabled() ) { if ( isExperimentalBlocksEnabled() ) {
const defaultAttribute = getSetting< AttributeSetting >(
'defaultProductFilterAttribute'
);
registerBlockType( metadata, { registerBlockType( metadata, {
edit: Edit, edit: Edit,
icon: productFilterOptions, icon: productFilterOptions,
attributes: {
...metadata.attributes,
attributeId: {
...metadata.attributes.attributeId,
default: parseInt( defaultAttribute.attribute_id, 10 ),
},
},
} ); } );
} }

View File

@ -3,13 +3,21 @@
*/ */
import { BlockEditProps } from '@wordpress/blocks'; import { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { sortOrders } from './constants';
export type BlockAttributes = { export type BlockAttributes = {
attributeId: number; attributeId: number;
showCounts: boolean; showCounts: boolean;
queryType: string; queryType: 'or' | 'and';
displayStyle: string; displayStyle: string;
selectType: string; selectType: string;
isPreview: boolean; isPreview: boolean;
sortOrder: keyof typeof sortOrders;
hideEmpty: boolean;
clearButton: boolean;
}; };
export interface EditProps extends BlockEditProps< BlockAttributes > { export interface EditProps extends BlockEditProps< BlockAttributes > {

View File

@ -1,34 +1,40 @@
/** /**
* External dependencies * External dependencies
*/ */
import {
InnerBlocks,
useBlockProps,
useInnerBlocksProps,
InspectorControls,
} from '@wordpress/block-editor';
import { BlockEditProps, InnerBlockTemplate } from '@wordpress/blocks';
import { __, sprintf } from '@wordpress/i18n';
import { useCollection } from '@woocommerce/base-context/hooks';
import { AttributeTerm } from '@woocommerce/types';
import {
PanelBody,
RadioControl,
Spinner,
ExternalLink,
RangeControl,
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
__experimentalToggleGroupControl as ToggleGroupControl,
} from '@wordpress/components';
import { Icon, settings, menu } from '@wordpress/icons';
import { filter, filterThreeLines } from '@woocommerce/icons'; import { filter, filterThreeLines } from '@woocommerce/icons';
import { getSetting } from '@woocommerce/settings'; import { getSetting } from '@woocommerce/settings';
import { AttributeSetting } from '@woocommerce/types';
import {
InnerBlocks,
InspectorControls,
useBlockProps,
useInnerBlocksProps,
} from '@wordpress/block-editor';
import { BlockEditProps, InnerBlockTemplate } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { Icon, menu, settings } from '@wordpress/icons';
import {
ExternalLink,
PanelBody,
RadioControl,
RangeControl,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import type { BlockAttributes } from './types';
import './editor.scss'; import './editor.scss';
import type { BlockAttributes } from './types';
const defaultAttribute = getSetting< AttributeSetting >(
'defaultProductFilterAttribute'
);
const TEMPLATE: InnerBlockTemplate[] = [ const TEMPLATE: InnerBlockTemplate[] = [
[ [
@ -64,8 +70,8 @@ const TEMPLATE: InnerBlockTemplate[] = [
'woocommerce/product-filter', 'woocommerce/product-filter',
{ {
filterType: 'attribute-filter', filterType: 'attribute-filter',
heading: __( 'Attribute', 'woocommerce' ), heading: defaultAttribute.attribute_label,
attributeId: 0, attributeId: parseInt( defaultAttribute.attribute_id, 10 ),
}, },
], ],
[ [
@ -101,73 +107,11 @@ const TEMPLATE: InnerBlockTemplate[] = [
], ],
]; ];
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,
metadata: {
name: sprintf(
/* translators: %s is referring to the filter attribute name. For example: Color, Size, etc. */
__( '%s (Experimental)', 'woocommerce' ),
highestProductCountAttribute.name
),
},
},
];
}
return block;
} );
};
export const Edit = ( { export const Edit = ( {
setAttributes, setAttributes,
attributes, attributes,
}: BlockEditProps< BlockAttributes > ) => { }: BlockEditProps< BlockAttributes > ) => {
const blockProps = useBlockProps(); const blockProps = useBlockProps();
const { results: attributeTerms, isLoading } =
useCollection< AttributeTerm >( {
namespace: '/wc/store/v1',
resourceName: 'products/attributes',
} );
const highestProductCountAttribute =
attributeTerms.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 />;
}
const templatePartEditUri = getSetting< string >( const templatePartEditUri = getSetting< string >(
'templatePartProductFiltersOverlayEditUri', 'templatePartProductFiltersOverlayEditUri',
@ -329,7 +273,7 @@ export const Edit = ( {
) } ) }
</PanelBody> </PanelBody>
</InspectorControls> </InspectorControls>
<InnerBlocks templateLock={ false } template={ updatedTemplate } /> <InnerBlocks templateLock={ false } template={ TEMPLATE } />
</div> </div>
); );
}; };

View File

@ -0,0 +1,154 @@
/**
* External dependencies
*/
import { test as base, expect } from '@woocommerce/e2e-utils';
/**
* Internal dependencies
*/
import { ProductFiltersPage } from './product-filters.page';
const blockData = {
name: 'woocommerce/product-filter-attribute',
selectors: {
frontend: {},
editor: {
settings: {},
},
},
slug: 'archive-product',
};
const test = base.extend< { pageObject: ProductFiltersPage } >( {
pageObject: async ( { page, editor, frontendUtils }, use ) => {
const pageObject = new ProductFiltersPage( {
page,
editor,
frontendUtils,
} );
await use( pageObject );
},
} );
test.describe( `${ blockData.name }`, () => {
test.beforeEach( async ( { admin, requestUtils } ) => {
await requestUtils.activatePlugin(
'woocommerce-blocks-test-enable-experimental-features'
);
await admin.visitSiteEditor( {
postId: `woocommerce/woocommerce//${ blockData.slug }`,
postType: 'wp_template',
canvas: 'edit',
} );
} );
test( 'should display the correct inspector style controls', async ( {
editor,
pageObject,
} ) => {
await pageObject.addProductFiltersBlock( { cleanContent: true } );
const block = editor.canvas
.getByLabel( 'Block: Attribute (Experimental)' )
.getByLabel( 'Block: Filter Options' );
await expect( block ).toBeVisible();
await block.click();
await editor.openDocumentSettingsSidebar();
await editor.page.getByRole( 'tab', { name: 'Styles' } ).click();
await expect(
editor.page.getByText( 'ColorAll options are currently hidden' )
).toBeVisible();
await expect(
editor.page.getByText(
'TypographyAll options are currently hidden'
)
).toBeVisible();
await expect(
editor.page.getByText(
'DimensionsAll options are currently hidden'
)
).toBeVisible();
await expect(
editor.page.getByText( 'DisplayListChips' )
).toBeVisible();
} );
test( 'should display the correct inspector setting controls', async ( {
editor,
pageObject,
} ) => {
await pageObject.addProductFiltersBlock( { cleanContent: true } );
const block = editor.canvas
.getByLabel( 'Block: Attribute (Experimental)' )
.getByLabel( 'Block: Filter Options' );
await expect( block ).toBeVisible();
await editor.openDocumentSettingsSidebar();
await block.click();
await expect(
editor.page.getByLabel( 'Editor settings' ).getByRole( 'button', {
name: 'Attribute',
exact: true,
} )
).toBeVisible();
await expect(
editor.page
.getByLabel( 'Editor settings' )
.getByRole( 'button', { name: 'Settings', exact: true } )
).toBeVisible();
await expect( editor.page.getByText( 'Sort order' ) ).toBeVisible();
await expect( editor.page.getByText( 'LogicAnyAll' ) ).toBeVisible();
} );
test( 'should dynamically set block title and heading based on the selected attribute', async ( {
editor,
pageObject,
} ) => {
await pageObject.addProductFiltersBlock( { cleanContent: true } );
const block = editor.canvas
.getByLabel( 'Block: Attribute (Experimental)' )
.getByLabel( 'Block: Filter Options' );
await editor.openDocumentSettingsSidebar();
await block.click();
await editor.page
.getByRole( 'tabpanel' )
.getByRole( 'combobox' )
.first()
.click();
await editor.page
.getByRole( 'option', { name: 'Size', exact: true } )
.click();
await pageObject.page.getByLabel( 'Document Overview' ).click();
const listView = pageObject.page.getByLabel( 'List View' );
await expect( listView ).toBeVisible();
const productFilterAttributeSizeBlockListItem = listView.getByText(
'Size (Experimental)' // it must select the attribute with the highest product count
);
await expect( productFilterAttributeSizeBlockListItem ).toBeVisible();
const productFilterAttributeWrapperBlock = editor.canvas.getByLabel(
'Block: Attribute (Experimental)'
);
await editor.selectBlocks( productFilterAttributeWrapperBlock );
await expect( productFilterAttributeWrapperBlock ).toBeVisible();
const productFilterAttributeBlockHeading =
productFilterAttributeWrapperBlock.getByText( 'Size', {
exact: true,
} );
await expect( productFilterAttributeBlockHeading ).toBeVisible();
} );
} );

View File

@ -439,72 +439,4 @@ test.describe( `${ blockData.name }`, () => {
block.locator( blockData.selectors.editor.layoutWrapper ) block.locator( blockData.selectors.editor.layoutWrapper )
).toHaveCSS( 'gap', '0px' ); ).toHaveCSS( 'gap', '0px' );
} ); } );
test.describe( 'product-filter-attribute', () => {
test( 'should dynamically set block title and heading based on the selected attribute', async ( {
editor,
pageObject,
} ) => {
await pageObject.addProductFiltersBlock( { cleanContent: true } );
await pageObject.page.getByLabel( 'Document Overview' ).click();
const listView = pageObject.page.getByLabel( 'List View' );
const productFiltersBlockListItem = listView.getByRole( 'link', {
name: 'Product Filters (Experimental)',
} );
await expect( productFiltersBlockListItem ).toBeVisible();
const listViewExpander =
pageObject.page.getByTestId( 'list-view-expander' );
const listViewExpanderIcon = listViewExpander.locator( 'svg' );
await listViewExpanderIcon.click();
const productFilterAttributeColorBlockListItem = listView.getByText(
'Color (Experimental)' // it must select the attribute with the highest product count
);
await expect(
productFilterAttributeColorBlockListItem
).toBeVisible();
const productFilterAttributeBlock = editor.canvas
.getByLabel( 'Block: Filter Options' )
.and(
editor.canvas.locator(
'[data-type="woocommerce/product-filter-attribute"]'
)
);
await editor.selectBlocks( productFilterAttributeBlock );
await editor.clickBlockToolbarButton( 'Edit' );
await editor.canvas
.locator( 'label' )
.filter( { hasText: 'Size' } )
.click();
await editor.canvas.getByRole( 'button', { name: 'Done' } ).click();
await expect(
productFilterAttributeColorBlockListItem
).toBeHidden();
const productFilterAttributeSizeBlockListItem = listView.getByText(
'Size (Experimental)' // it must select the attribute with the highest product count
);
await expect(
productFilterAttributeSizeBlockListItem
).toBeVisible();
const productFilterAttributeWrapperBlock = editor.canvas.getByLabel(
'Block: Attribute (Experimental)'
);
await editor.selectBlocks( productFilterAttributeWrapperBlock );
await expect( productFilterAttributeWrapperBlock ).toBeVisible();
const productFilterAttributeBlockHeading =
productFilterAttributeWrapperBlock.getByText( 'Size', {
exact: true,
} );
await expect( productFilterAttributeBlockHeading ).toBeVisible();
} );
} );
} ); } );

View File

@ -0,0 +1,5 @@
Significance: patch
Type: update
Comment: [Experimental] Attribute Filter: Update Inspector Control Settings

View File

@ -30,6 +30,33 @@ final class ProductFilterAttribute extends AbstractBlock {
add_filter( 'collection_filter_query_param_keys', array( $this, 'get_filter_query_param_keys' ), 10, 2 ); add_filter( 'collection_filter_query_param_keys', array( $this, 'get_filter_query_param_keys' ), 10, 2 );
add_filter( 'collection_active_filters_data', array( $this, 'register_active_filters_data' ), 10, 2 ); add_filter( 'collection_active_filters_data', array( $this, 'register_active_filters_data' ), 10, 2 );
add_action( 'deleted_transient', array( $this, 'delete_default_attribute_id_transient' ) );
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = array() ) {
parent::enqueue_data( $attributes );
if ( is_admin() ) {
$this->asset_data_registry->add( 'defaultProductFilterAttribute', $this->get_default_attribute() );
}
}
/**
* Delete the default attribute id transient when the attribute taxonomies are deleted.
*
* @param string $transient The transient name.
*/
public function delete_default_attribute_id_transient( $transient ) {
if ( 'wc_attribute_taxonomies' === $transient ) {
delete_transient( 'wc_block_product_filter_attribute_default_attribute' );
}
} }
/** /**
@ -43,7 +70,7 @@ final class ProductFilterAttribute extends AbstractBlock {
public function get_filter_query_param_keys( $filter_param_keys, $url_param_keys ) { public function get_filter_query_param_keys( $filter_param_keys, $url_param_keys ) {
$attribute_param_keys = array_filter( $attribute_param_keys = array_filter(
$url_param_keys, $url_param_keys,
function( $param ) { function ( $param ) {
return strpos( $param, 'filter_' ) === 0 || strpos( $param, 'query_type_' ) === 0; return strpos( $param, 'filter_' ) === 0 || strpos( $param, 'query_type_' ) === 0;
} }
); );
@ -64,7 +91,7 @@ final class ProductFilterAttribute extends AbstractBlock {
public function register_active_filters_data( $data, $params ) { public function register_active_filters_data( $data, $params ) {
$product_attributes_map = array_reduce( $product_attributes_map = array_reduce(
wc_get_attribute_taxonomies(), wc_get_attribute_taxonomies(),
function( $acc, $attribute_object ) { function ( $acc, $attribute_object ) {
$acc[ $attribute_object->attribute_name ] = $attribute_object->attribute_label; $acc[ $attribute_object->attribute_name ] = $attribute_object->attribute_label;
return $acc; return $acc;
}, },
@ -73,7 +100,7 @@ final class ProductFilterAttribute extends AbstractBlock {
$active_product_attributes = array_reduce( $active_product_attributes = array_reduce(
array_keys( $params ), array_keys( $params ),
function( $acc, $attribute ) { function ( $acc, $attribute ) {
if ( strpos( $attribute, 'filter_' ) === 0 ) { if ( strpos( $attribute, 'filter_' ) === 0 ) {
$acc[] = str_replace( 'filter_', '', $attribute ); $acc[] = str_replace( 'filter_', '', $attribute );
} }
@ -84,7 +111,7 @@ final class ProductFilterAttribute extends AbstractBlock {
$active_product_attributes = array_filter( $active_product_attributes = array_filter(
$active_product_attributes, $active_product_attributes,
function( $item ) use ( $product_attributes_map ) { function ( $item ) use ( $product_attributes_map ) {
return in_array( $item, array_keys( $product_attributes_map ), true ); return in_array( $item, array_keys( $product_attributes_map ), true );
} }
); );
@ -96,7 +123,7 @@ final class ProductFilterAttribute extends AbstractBlock {
// Get attribute term by slug. // Get attribute term by slug.
$terms = array_map( $terms = array_map(
function( $term ) use ( $product_attribute, $action_namespace ) { function ( $term ) use ( $product_attribute, $action_namespace ) {
$term_object = get_term_by( 'slug', $term, "pa_{$product_attribute}" ); $term_object = get_term_by( 'slug', $term, "pa_{$product_attribute}" );
return array( return array(
'title' => $term_object->name, 'title' => $term_object->name,
@ -168,7 +195,7 @@ final class ProductFilterAttribute extends AbstractBlock {
); );
$attribute_options = array_map( $attribute_options = array_map(
function( $term ) use ( $attribute_counts, $selected_terms ) { function ( $term ) use ( $attribute_counts, $selected_terms ) {
$term = (array) $term; $term = (array) $term;
$term['count'] = $attribute_counts[ $term['term_id'] ]; $term['count'] = $attribute_counts[ $term['term_id'] ];
$term['selected'] = in_array( $term['slug'], $selected_terms, true ); $term['selected'] = in_array( $term['slug'], $selected_terms, true );
@ -179,7 +206,7 @@ final class ProductFilterAttribute extends AbstractBlock {
$filtered_options = array_filter( $filtered_options = array_filter(
$attribute_options, $attribute_options,
function( $option ) { function ( $option ) {
return $option['count'] > 0; return $option['count'] > 0;
} }
); );
@ -191,7 +218,7 @@ final class ProductFilterAttribute extends AbstractBlock {
$context = array( $context = array(
'attributeSlug' => str_replace( 'pa_', '', $product_attribute->slug ), 'attributeSlug' => str_replace( 'pa_', '', $product_attribute->slug ),
'queryType' => $attributes['queryType'], 'queryType' => $attributes['queryType'],
'selectType' => $attributes['selectType'], 'selectType' => 'multiple',
); );
return sprintf( return sprintf(
@ -242,7 +269,7 @@ final class ProductFilterAttribute extends AbstractBlock {
'items' => $list_items, 'items' => $list_items,
'action' => "{$this->get_full_block_name()}::actions.navigate", 'action' => "{$this->get_full_block_name()}::actions.navigate",
'selected_items' => $selected_items, 'selected_items' => $selected_items,
'select_type' => $attributes['selectType'] ?? 'multiple', 'select_type' => 'multiple',
// translators: %s is a product attribute name. // translators: %s is a product attribute name.
'placeholder' => sprintf( __( 'Select %s', 'woocommerce' ), $product_attribute->name ), 'placeholder' => sprintf( __( 'Select %s', 'woocommerce' ), $product_attribute->name ),
) )
@ -264,7 +291,7 @@ final class ProductFilterAttribute extends AbstractBlock {
$show_counts = $attributes['showCounts'] ?? false; $show_counts = $attributes['showCounts'] ?? false;
$list_options = array_map( $list_options = array_map(
function( $option ) use ( $show_counts ) { function ( $option ) use ( $show_counts ) {
return array( return array(
'id' => $option['slug'] . '-' . $option['term_id'], 'id' => $option['slug'] . '-' . $option['term_id'],
'checked' => $option['selected'], 'checked' => $option['selected'],
@ -320,13 +347,72 @@ final class ProductFilterAttribute extends AbstractBlock {
$attribute_counts = array_reduce( $attribute_counts = array_reduce(
$attribute_counts, $attribute_counts,
function( $acc, $count ) { function ( $acc, $count ) {
$acc[ $count['term'] ] = $count['count']; $acc[ $count['term'] ] = $count['count'];
return $acc; return $acc;
}, },
[] array()
); );
return $attribute_counts; return $attribute_counts;
} }
/**
* Get the attribute if with most term but closest to 30 terms.
*
* @return int
*/
private function get_default_attribute() {
$cached = get_transient( 'wc_block_product_filter_attribute_default_attribute' );
if ( $cached ) {
return $cached;
}
$attributes = wc_get_attribute_taxonomies();
$attributes_count = array_map(
function ( $attribute ) {
return intval(
wp_count_terms(
array(
'taxonomy' => 'pa_' . $attribute->attribute_name,
'hide_empty' => false,
)
)
);
},
$attributes
);
asort( $attributes_count );
$search = 30;
$closest = null;
$attribute_id = null;
foreach ( $attributes_count as $id => $count ) {
if ( null === $closest || abs( $search - $closest ) > abs( $count - $search ) ) {
$closest = $count;
$attribute_id = $id;
}
if ( $closest && $count >= $search ) {
break;
}
}
$default_attribute = array(
'id' => 0,
'label' => __( 'Attribute', 'woocommerce' ),
);
if ( $attribute_id ) {
$default_attribute = $attributes[ $attribute_id ];
}
set_transient( 'wc_block_product_filter_attribute_default_attribute', $default_attribute );
return $default_attribute;
}
} }