New block: Products by Attribute (https://github.com/woocommerce/woocommerce-blocks/pull/378)
* Add Products by Attribute block * Remove attributes property from sharedAttributes * Add operator control to Products Attribute selector * Fix default operator queries * Add the initial “edit mode” placeholder screen * Style attribute selector better * Add ‘edit mode’ toggle to toolbar * Add orderby control
This commit is contained in:
parent
284d98643d
commit
ac96484f7a
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import apiFetch from '@wordpress/api-fetch';
|
||||
import { BlockControls, InspectorControls } from '@wordpress/editor';
|
||||
import {
|
||||
Button,
|
||||
PanelBody,
|
||||
Placeholder,
|
||||
RangeControl,
|
||||
Spinner,
|
||||
Toolbar,
|
||||
withSpokenMessages,
|
||||
} from '@wordpress/components';
|
||||
import { Component, Fragment } from '@wordpress/element';
|
||||
import { debounce } from 'lodash';
|
||||
import Gridicon from 'gridicons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import getQuery from '../../utils/get-query';
|
||||
import ProductAttributeControl from '../../components/product-attribute-control';
|
||||
import ProductOrderbyControl from '../../components/product-orderby-control';
|
||||
import ProductPreview from '../../components/product-preview';
|
||||
|
||||
/**
|
||||
* Component to handle edit mode of "Products by Attribute".
|
||||
*/
|
||||
class ProductsByAttributeBlock extends Component {
|
||||
constructor() {
|
||||
super( ...arguments );
|
||||
this.state = {
|
||||
products: [],
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
this.debouncedGetProducts = debounce( this.getProducts.bind( this ), 200 );
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if ( this.props.attributes.attributes ) {
|
||||
this.getProducts();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate( prevProps ) {
|
||||
const hasChange = [
|
||||
'attributes',
|
||||
'attrOperator',
|
||||
'columns',
|
||||
'orderby',
|
||||
'rows',
|
||||
].reduce( ( acc, key ) => {
|
||||
return acc || prevProps.attributes[ key ] !== this.props.attributes[ key ];
|
||||
}, false );
|
||||
if ( hasChange ) {
|
||||
this.debouncedGetProducts();
|
||||
}
|
||||
}
|
||||
|
||||
getProducts() {
|
||||
const blockAttributes = this.props.attributes;
|
||||
if ( ! blockAttributes.attributes.length ) {
|
||||
// We've removed all selected attributes, or no attributes have been selected yet.
|
||||
this.setState( { products: [], loaded: true } );
|
||||
return;
|
||||
}
|
||||
apiFetch( {
|
||||
path: addQueryArgs(
|
||||
'/wc-pb/v3/products',
|
||||
getQuery( blockAttributes, this.props.name )
|
||||
),
|
||||
} )
|
||||
.then( ( products ) => {
|
||||
this.setState( { products, loaded: true } );
|
||||
} )
|
||||
.catch( () => {
|
||||
this.setState( { products: [], loaded: true } );
|
||||
} );
|
||||
}
|
||||
|
||||
getInspectorControls() {
|
||||
const { setAttributes } = this.props;
|
||||
const { attributes, attrOperator, columns, orderby, rows } = this.props.attributes;
|
||||
|
||||
return (
|
||||
<InspectorControls key="inspector">
|
||||
<PanelBody
|
||||
title={ __( 'Layout', 'woo-gutenberg-products-block' ) }
|
||||
initialOpen
|
||||
>
|
||||
<RangeControl
|
||||
label={ __( 'Columns', 'woo-gutenberg-products-block' ) }
|
||||
value={ columns }
|
||||
onChange={ ( value ) => setAttributes( { columns: value } ) }
|
||||
min={ wc_product_block_data.min_columns }
|
||||
max={ wc_product_block_data.max_columns }
|
||||
/>
|
||||
<RangeControl
|
||||
label={ __( 'Rows', 'woo-gutenberg-products-block' ) }
|
||||
value={ rows }
|
||||
onChange={ ( value ) => setAttributes( { rows: value } ) }
|
||||
min={ wc_product_block_data.min_rows }
|
||||
max={ wc_product_block_data.max_rows }
|
||||
/>
|
||||
</PanelBody>
|
||||
<PanelBody
|
||||
title={ __(
|
||||
'Filter by Product Attribute',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
initialOpen={ false }
|
||||
>
|
||||
<ProductAttributeControl
|
||||
selected={ attributes }
|
||||
onChange={ ( value = [] ) => {
|
||||
const result = value.map( ( { id, attr_slug } ) => ( { // eslint-disable-line camelcase
|
||||
id,
|
||||
attr_slug,
|
||||
} ) );
|
||||
setAttributes( { attributes: result } );
|
||||
} }
|
||||
operator={ attrOperator }
|
||||
onOperatorChange={ ( value = 'any' ) =>
|
||||
setAttributes( { attrOperator: value } )
|
||||
}
|
||||
/>
|
||||
</PanelBody>
|
||||
<PanelBody
|
||||
title={ __( 'Order By', 'woo-gutenberg-products-block' ) }
|
||||
initialOpen={ false }
|
||||
>
|
||||
<ProductOrderbyControl
|
||||
setAttributes={ setAttributes }
|
||||
value={ orderby }
|
||||
/>
|
||||
</PanelBody>
|
||||
</InspectorControls>
|
||||
);
|
||||
}
|
||||
|
||||
renderEditMode() {
|
||||
const { debouncedSpeak, setAttributes } = this.props;
|
||||
const blockAttributes = this.props.attributes;
|
||||
const onDone = () => {
|
||||
setAttributes( { editMode: false } );
|
||||
debouncedSpeak(
|
||||
__(
|
||||
'Showing Products by Attribute block preview.',
|
||||
'woo-gutenberg-products-block'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Placeholder
|
||||
icon={ <Gridicon icon="custom-post-type" /> }
|
||||
label={ __( 'Products by Attribute', 'woo-gutenberg-products-block' ) }
|
||||
className="wc-block-products-grid wc-block-products-attribute"
|
||||
>
|
||||
{ __(
|
||||
'Display a grid of products from your selected attributes.',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
<div className="wc-block-products-attribute__selection">
|
||||
<ProductAttributeControl
|
||||
selected={ blockAttributes.attributes }
|
||||
onChange={ ( value = [] ) => {
|
||||
const result = value.map( ( { id, attr_slug } ) => ( { // eslint-disable-line camelcase
|
||||
id,
|
||||
attr_slug,
|
||||
} ) );
|
||||
setAttributes( { attributes: result } );
|
||||
} }
|
||||
operator={ blockAttributes.attrOperator }
|
||||
onOperatorChange={ ( value = 'any' ) =>
|
||||
setAttributes( { attrOperator: value } )
|
||||
}
|
||||
/>
|
||||
<Button isDefault onClick={ onDone }>
|
||||
{ __( 'Done', 'woo-gutenberg-products-block' ) }
|
||||
</Button>
|
||||
</div>
|
||||
</Placeholder>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { setAttributes } = this.props;
|
||||
const { columns, editMode } = this.props.attributes;
|
||||
const { loaded, products } = this.state;
|
||||
const classes = [ 'wc-block-products-grid', 'wc-block-products-attribute' ];
|
||||
if ( columns ) {
|
||||
classes.push( `cols-${ columns }` );
|
||||
}
|
||||
if ( products && ! products.length ) {
|
||||
if ( ! loaded ) {
|
||||
classes.push( 'is-loading' );
|
||||
} else {
|
||||
classes.push( 'is-not-found' );
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<BlockControls>
|
||||
<Toolbar
|
||||
controls={ [
|
||||
{
|
||||
icon: 'edit',
|
||||
title: __( 'Edit' ),
|
||||
onClick: () => setAttributes( { editMode: ! editMode } ),
|
||||
isActive: editMode,
|
||||
},
|
||||
] }
|
||||
/>
|
||||
</BlockControls>
|
||||
{ this.getInspectorControls() }
|
||||
{ editMode ? (
|
||||
this.renderEditMode()
|
||||
) : (
|
||||
<div className={ classes.join( ' ' ) }>
|
||||
{ products.length ? (
|
||||
products.map( ( product ) => (
|
||||
<ProductPreview product={ product } key={ product.id } />
|
||||
) )
|
||||
) : (
|
||||
<Placeholder
|
||||
icon={ <Gridicon icon="custom-post-type" /> }
|
||||
label={ __(
|
||||
'Products by Attribute',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
>
|
||||
{ ! loaded ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
__( 'No products found.', 'woo-gutenberg-products-block' )
|
||||
) }
|
||||
</Placeholder>
|
||||
) }
|
||||
</div>
|
||||
) }
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ProductsByAttributeBlock.propTypes = {
|
||||
/**
|
||||
* The attributes for this block
|
||||
*/
|
||||
attributes: PropTypes.object.isRequired,
|
||||
/**
|
||||
* The register block name.
|
||||
*/
|
||||
name: PropTypes.string.isRequired,
|
||||
/**
|
||||
* A callback to update attributes
|
||||
*/
|
||||
setAttributes: PropTypes.func.isRequired,
|
||||
// from withSpokenMessages
|
||||
debouncedSpeak: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default withSpokenMessages( ProductsByAttributeBlock );
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import Gridicon from 'gridicons';
|
||||
import { registerBlockType } from '@wordpress/blocks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
import Block from './block';
|
||||
|
||||
registerBlockType( 'woocommerce/products-by-attribute', {
|
||||
title: __( 'Products by Attribute', 'woo-gutenberg-products-block' ),
|
||||
icon: <Gridicon icon="custom-post-type" />,
|
||||
category: 'woocommerce',
|
||||
keywords: [ __( 'WooCommerce', 'woo-gutenberg-products-block' ) ],
|
||||
description: __(
|
||||
'Display a grid of products from your selected attributes.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
supports: {
|
||||
align: [ 'wide', 'full' ],
|
||||
},
|
||||
attributes: {
|
||||
/**
|
||||
* Product attributes, used to display only products with the given attributes.
|
||||
*/
|
||||
attributes: {
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
|
||||
/**
|
||||
* Product attribute operator, used to restrict to products in all or any selected attributes.
|
||||
*/
|
||||
attrOperator: {
|
||||
type: 'string',
|
||||
default: 'any',
|
||||
},
|
||||
|
||||
/**
|
||||
* Number of columns.
|
||||
*/
|
||||
columns: {
|
||||
type: 'number',
|
||||
default: wc_product_block_data.default_columns,
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle for edit mode in the block preview.
|
||||
*/
|
||||
editMode: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* How to order the products: 'date', 'popularity', 'price_asc', 'price_desc' 'rating', 'title'.
|
||||
*/
|
||||
orderby: {
|
||||
type: 'string',
|
||||
default: 'date',
|
||||
},
|
||||
|
||||
/**
|
||||
* Number of rows.
|
||||
*/
|
||||
rows: {
|
||||
type: 'number',
|
||||
default: wc_product_block_data.default_rows,
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders and manages the block.
|
||||
*/
|
||||
edit( props ) {
|
||||
return <Block { ...props } />;
|
||||
},
|
||||
|
||||
/**
|
||||
* Block content is rendered in PHP, not via save function.
|
||||
*/
|
||||
save() {
|
||||
return null;
|
||||
},
|
||||
} );
|
|
@ -0,0 +1,3 @@
|
|||
.wc-block-products-attribute__selection {
|
||||
width: 100%;
|
||||
}
|
|
@ -4,9 +4,10 @@
|
|||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import apiFetch from '@wordpress/api-fetch';
|
||||
import { Component } from '@wordpress/element';
|
||||
import { Component, Fragment } from '@wordpress/element';
|
||||
import { find } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { SelectControl } from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -89,7 +90,7 @@ class ProductAttributeControl extends Component {
|
|||
|
||||
render() {
|
||||
const { list, loading } = this.state;
|
||||
const { selected, onChange } = this.props;
|
||||
const { onChange, onOperatorChange, operator = 'any', selected } = this.props;
|
||||
|
||||
const messages = {
|
||||
clear: __( 'Clear all product attributes', 'woo-gutenberg-products-block' ),
|
||||
|
@ -119,16 +120,39 @@ class ProductAttributeControl extends Component {
|
|||
};
|
||||
|
||||
return (
|
||||
<SearchListControl
|
||||
className="woocommerce-product-attributes"
|
||||
list={ list }
|
||||
isLoading={ loading }
|
||||
selected={ selected.map( ( { id } ) => find( list, { id } ) ).filter( Boolean ) }
|
||||
onChange={ onChange }
|
||||
renderItem={ this.renderItem }
|
||||
messages={ messages }
|
||||
isHierarchical
|
||||
/>
|
||||
<Fragment>
|
||||
<SearchListControl
|
||||
className="woocommerce-product-attributes"
|
||||
list={ list }
|
||||
isLoading={ loading }
|
||||
selected={ selected.map( ( { id } ) => find( list, { id } ) ).filter( Boolean ) }
|
||||
onChange={ onChange }
|
||||
renderItem={ this.renderItem }
|
||||
messages={ messages }
|
||||
isHierarchical
|
||||
/>
|
||||
{ ( !! onOperatorChange ) && (
|
||||
<div className={ selected.length < 2 ? 'screen-reader-text' : '' }>
|
||||
<SelectControl
|
||||
className="woocommerce-product-attributes__operator"
|
||||
label={ __( 'Display products matching', 'woo-gutenberg-products-block' ) }
|
||||
help={ __( 'Pick at least two attributes to use this setting.', 'woo-gutenberg-products-block' ) }
|
||||
value={ operator }
|
||||
onChange={ onOperatorChange }
|
||||
options={ [
|
||||
{
|
||||
label: __( 'Any selected attributes', 'woo-gutenberg-products-block' ),
|
||||
value: 'any',
|
||||
},
|
||||
{
|
||||
label: __( 'All selected attributes', 'woo-gutenberg-products-block' ),
|
||||
value: 'all',
|
||||
},
|
||||
] }
|
||||
/>
|
||||
</div>
|
||||
) }
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -138,6 +162,14 @@ ProductAttributeControl.propTypes = {
|
|||
* Callback to update the selected product attributes.
|
||||
*/
|
||||
onChange: PropTypes.func.isRequired,
|
||||
/**
|
||||
* Callback to update the category operator. If not passed in, setting is not used.
|
||||
*/
|
||||
onOperatorChange: PropTypes.func,
|
||||
/**
|
||||
* Setting for whether products should match all or any selected categories.
|
||||
*/
|
||||
operator: PropTypes.oneOf( [ 'all', 'any' ] ),
|
||||
/**
|
||||
* The list of currently selected attribute slug/ID pairs.
|
||||
*/
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
.woocommerce-product-attributes__operator {
|
||||
.components-base-control__help {
|
||||
@include visually-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.components-panel {
|
||||
.woocommerce-product-attributes__operator.components-base-control {
|
||||
margin-top: $gap;
|
||||
|
||||
.components-select-control__input {
|
||||
margin-left: 0;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-search-list__item.woocommerce-product-attributes__item {
|
||||
&.is-searching,
|
||||
&.is-skip-level {
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
export default function getQuery( attributes, name ) {
|
||||
const { categories, catOperator, columns, orderby, products, rows } = attributes;
|
||||
export default function getQuery( blockAttributes, name ) {
|
||||
const {
|
||||
attributes,
|
||||
attrOperator,
|
||||
categories,
|
||||
catOperator,
|
||||
columns,
|
||||
orderby,
|
||||
products,
|
||||
rows,
|
||||
} = blockAttributes;
|
||||
|
||||
const query = {
|
||||
status: 'publish',
|
||||
|
@ -31,6 +40,22 @@ export default function getQuery( attributes, name ) {
|
|||
}
|
||||
}
|
||||
|
||||
if ( attributes ) {
|
||||
query.attributes = attributes.reduce( ( accumulator, { attr_slug, id } ) => { // eslint-disable-line camelcase
|
||||
if ( accumulator[ attr_slug ] ) {
|
||||
accumulator[ attr_slug ].push( id );
|
||||
} else {
|
||||
accumulator[ attr_slug ] = [ id ];
|
||||
}
|
||||
return accumulator;
|
||||
}, {} );
|
||||
|
||||
if ( attrOperator ) {
|
||||
query.attr_operator = 'all' === attrOperator ? 'AND' : 'IN';
|
||||
query.tax_relation = 'all' === attrOperator ? 'AND' : 'OR';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle query parameters depending on block type.
|
||||
switch ( name ) {
|
||||
case 'woocommerce/product-best-sellers':
|
||||
|
|
|
@ -30,12 +30,4 @@ export default {
|
|||
type: 'string',
|
||||
default: 'any',
|
||||
},
|
||||
|
||||
/**
|
||||
* Product attributes, used to display only products with the given attributes.
|
||||
*/
|
||||
attributes: {
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -36,6 +36,7 @@ const GutenbergBlocksConfig = {
|
|||
'product-new': './assets/js/blocks/product-new/index.js',
|
||||
'product-on-sale': './assets/js/blocks/product-on-sale/index.js',
|
||||
'product-top-rated': './assets/js/blocks/product-top-rated/index.js',
|
||||
'products-attribute': './assets/js/blocks/products-by-attribute/index.js',
|
||||
'featured-product': './assets/js/blocks/featured-product/index.js',
|
||||
'products-grid': './assets/css/products-grid.scss',
|
||||
},
|
||||
|
|
|
@ -130,6 +130,13 @@ function wgpb_register_blocks() {
|
|||
'editor_style' => 'wc-product-top-rated-editor',
|
||||
)
|
||||
);
|
||||
register_block_type(
|
||||
'woocommerce/products-by-attribute',
|
||||
array(
|
||||
'editor_script' => 'wc-products-attribute',
|
||||
'editor_style' => 'wc-products-attribute-editor',
|
||||
)
|
||||
);
|
||||
register_block_type(
|
||||
'woocommerce/featured-product',
|
||||
array(
|
||||
|
@ -326,6 +333,25 @@ function wgpb_register_scripts() {
|
|||
wp_set_script_translations( 'wc-featured-product', 'woo-gutenberg-products-block', plugin_dir_path( __FILE__ ) . 'languages' );
|
||||
}
|
||||
|
||||
wp_register_script(
|
||||
'wc-products-attribute',
|
||||
plugins_url( 'build/products-attribute.js', __FILE__ ),
|
||||
$block_dependencies,
|
||||
wgpb_get_file_version( '/build/products-attribute.js' ),
|
||||
true
|
||||
);
|
||||
|
||||
wp_register_style(
|
||||
'wc-products-attribute-editor',
|
||||
plugins_url( 'build/products-attribute.css', __FILE__ ),
|
||||
array( 'wc-vendors', 'wp-edit-blocks', 'wc-products-grid' ),
|
||||
wgpb_get_file_version( '/build/products-attribute.css' )
|
||||
);
|
||||
|
||||
if ( function_exists( 'wp_set_script_translations' ) ) {
|
||||
wp_set_script_translations( 'wc-products-attribute', 'woo-gutenberg-products-block', plugin_dir_path( __FILE__ ) . 'languages' );
|
||||
}
|
||||
|
||||
wp_register_script(
|
||||
'woocommerce-products-block-editor',
|
||||
plugins_url( 'build/products-block.js', __FILE__ ),
|
||||
|
|
Loading…
Reference in New Issue