* 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:
Kelly Dwan 2019-01-30 16:27:56 -05:00 committed by GitHub
parent 284d98643d
commit ac96484f7a
9 changed files with 477 additions and 22 deletions

View File

@ -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 );

View File

@ -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;
},
} );

View File

@ -0,0 +1,3 @@
.wc-block-products-attribute__selection {
width: 100%;
}

View File

@ -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.
*/

View File

@ -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 {

View File

@ -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':

View File

@ -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: [],
},
};

View File

@ -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',
},

View File

@ -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__ ),