diff --git a/plugins/woocommerce-blocks/assets/js/components/product-attribute-control/index.js b/plugins/woocommerce-blocks/assets/js/components/product-attribute-control/index.js
new file mode 100644
index 00000000000..77d8000ded5
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/components/product-attribute-control/index.js
@@ -0,0 +1,147 @@
+/**
+ * External dependencies
+ */
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { addQueryArgs } from '@wordpress/url';
+import apiFetch from '@wordpress/api-fetch';
+import { Component } from '@wordpress/element';
+import { find } from 'lodash';
+import PropTypes from 'prop-types';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import SearchListControl from '../search-list-control';
+import SearchListItem from '../search-list-control/item';
+
+class ProductAttributeControl extends Component {
+ constructor() {
+ super( ...arguments );
+ this.state = {
+ list: [],
+ loading: true,
+ };
+ }
+
+ componentDidMount() {
+ const getTermsInAttribute = ( { id } ) => {
+ return apiFetch( {
+ path: addQueryArgs( `/wc-pb/v3/products/attributes/${ id }/terms`, {
+ per_page: -1,
+ } ),
+ } ).then( ( terms ) => terms.map( ( t ) => ( { ...t, parent: id } ) ) );
+ };
+
+ apiFetch( {
+ path: addQueryArgs( '/wc-pb/v3/products/attributes', { per_page: -1 } ),
+ } )
+ .then( ( attributes ) => {
+ // Fetch the terms list for each attribute group, then flatten them into one list.
+ Promise.all( attributes.map( getTermsInAttribute ) ).then( ( results ) => {
+ const list = attributes.map( ( a ) => ( { ...a, parent: 0 } ) );
+ results.forEach( ( terms ) => {
+ list.push( ...terms );
+ } );
+ this.setState( { list, loading: false } );
+ } );
+ } )
+ .catch( () => {
+ this.setState( { list: [], loading: false } );
+ } );
+ }
+
+ renderItem( args ) {
+ const { item, search, depth = 0 } = args;
+ const classes = [
+ 'woocommerce-product-attributes__item',
+ 'woocommerce-search-list__item',
+ ];
+ if ( search.length ) {
+ classes.push( 'is-searching' );
+ }
+ if ( depth === 0 && item.parent !== 0 ) {
+ classes.push( 'is-skip-level' );
+ }
+
+ if ( ! item.breadcrumbs.length ) {
+ classes.push( 'is-not-active' );
+ return (
+
+
+
+ { item.name }
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ render() {
+ const { list, loading } = this.state;
+ const { selected, onChange } = this.props;
+
+ const messages = {
+ clear: __( 'Clear all product attributes', 'woo-gutenberg-products-block' ),
+ list: __( 'Product Attributes', 'woo-gutenberg-products-block' ),
+ noItems: __(
+ "Your store doesn't have any product attributes.",
+ 'woo-gutenberg-products-block'
+ ),
+ search: __(
+ 'Search for product attributes',
+ 'woo-gutenberg-products-block'
+ ),
+ selected: ( n ) =>
+ sprintf(
+ _n(
+ '%d attribute selected',
+ '%d attributes selected',
+ n,
+ 'woo-gutenberg-products-block'
+ ),
+ n
+ ),
+ updated: __(
+ 'Product attribute search results updated.',
+ 'woo-gutenberg-products-block'
+ ),
+ };
+
+ return (
+ find( list, { id } ) ).filter( Boolean ) }
+ onChange={ onChange }
+ renderItem={ this.renderItem }
+ messages={ messages }
+ isHierarchical
+ />
+ );
+ }
+}
+
+ProductAttributeControl.propTypes = {
+ /**
+ * Callback to update the selected product attributes.
+ */
+ onChange: PropTypes.func.isRequired,
+ /**
+ * The list of currently selected attribute slug/ID pairs.
+ */
+ selected: PropTypes.array.isRequired,
+};
+
+export default ProductAttributeControl;
diff --git a/plugins/woocommerce-blocks/assets/js/components/product-attribute-control/style.scss b/plugins/woocommerce-blocks/assets/js/components/product-attribute-control/style.scss
new file mode 100644
index 00000000000..58306776cc9
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/components/product-attribute-control/style.scss
@@ -0,0 +1,14 @@
+.woocommerce-search-list__item.woocommerce-product-attributes__item {
+ &.is-searching,
+ &.is-skip-level {
+ .woocommerce-search-list__item-prefix:after {
+ content: ":";
+ }
+ }
+
+ &.is-not-active {
+ @include hover-state {
+ background: transparent;
+ }
+ }
+}
diff --git a/plugins/woocommerce-blocks/assets/js/components/search-list-control/style.scss b/plugins/woocommerce-blocks/assets/js/components/search-list-control/style.scss
index a022ad3e7f0..2f3039b6dbc 100644
--- a/plugins/woocommerce-blocks/assets/js/components/search-list-control/style.scss
+++ b/plugins/woocommerce-blocks/assets/js/components/search-list-control/style.scss
@@ -95,7 +95,7 @@
background: $core-grey-light-100;
}
- &:last-of-type {
+ &:last-child {
border-bottom: none !important;
}
diff --git a/plugins/woocommerce-blocks/assets/js/product-category-block.js b/plugins/woocommerce-blocks/assets/js/product-category-block.js
index cb22de7047f..9adc50e60ff 100644
--- a/plugins/woocommerce-blocks/assets/js/product-category-block.js
+++ b/plugins/woocommerce-blocks/assets/js/product-category-block.js
@@ -26,6 +26,7 @@ import PropTypes from 'prop-types';
* Internal dependencies
*/
import getQuery from './utils/get-query';
+import ProductAttributeControl from './components/product-attribute-control';
import ProductCategoryControl from './components/product-category-control';
import ProductOrderbyControl from './components/product-orderby-control';
import ProductPreview from './components/product-preview';
@@ -130,6 +131,18 @@ class ProductByCategoryBlock extends Component {
value={ orderby }
/>
+
+ {
+ const selected = value.map( ( { id, attr_slug } ) => ( { id, attr_slug } ) ); // eslint-disable-line camelcase
+ setAttributes( { attributes: selected } );
+ } }
+ />
+
);
}
diff --git a/plugins/woocommerce-blocks/assets/js/utils/shared-attributes.js b/plugins/woocommerce-blocks/assets/js/utils/shared-attributes.js
index 3891403eed7..17c5261909e 100644
--- a/plugins/woocommerce-blocks/assets/js/utils/shared-attributes.js
+++ b/plugins/woocommerce-blocks/assets/js/utils/shared-attributes.js
@@ -37,4 +37,12 @@ export default {
type: 'string',
default: 'any',
},
+
+ /**
+ * Product attributes, used to display only products with the given attributes.
+ */
+ attributes: {
+ type: 'array',
+ default: [],
+ },
};