diff --git a/assets/js/base/components/checkbox-list/index.js b/assets/js/base/components/checkbox-list/index.js
new file mode 100644
index 00000000000..24bf585bcbc
--- /dev/null
+++ b/assets/js/base/components/checkbox-list/index.js
@@ -0,0 +1,191 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import PropTypes from 'prop-types';
+import {
+ Fragment,
+ useCallback,
+ useMemo,
+ useState,
+ useEffect,
+} from '@wordpress/element';
+import classNames from 'classnames';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+/**
+ * Component used to show a list of checkboxes in a group.
+ */
+const CheckboxList = ( {
+ className,
+ onChange = () => {},
+ options = [],
+ isLoading = false,
+ limit = 10,
+} ) => {
+ // Holds all checked options.
+ const [ checked, setChecked ] = useState( [] );
+ const [ showExpanded, setShowExpanded ] = useState( false );
+
+ useEffect( () => {
+ onChange( checked );
+ }, [ checked ] );
+
+ const placeholder = useMemo( () => {
+ return [ ...Array( 5 ) ].map( ( x, i ) => (
+
+ ) );
+ }, [] );
+
+ const onCheckboxChange = useCallback(
+ ( event ) => {
+ const isChecked = event.target.checked;
+ const checkedValue = event.target.value;
+ const newChecked = checked.filter(
+ ( value ) => value !== checkedValue
+ );
+
+ if ( isChecked ) {
+ newChecked.push( checkedValue );
+ newChecked.sort();
+ }
+
+ setChecked( newChecked );
+ },
+ [ checked ]
+ );
+
+ const renderedShowMore = useMemo( () => {
+ const optionCount = options.length;
+ return (
+ ! showExpanded && (
+
+
+
+ )
+ );
+ }, [ options, limit, showExpanded ] );
+
+ const renderedShowLess = useMemo( () => {
+ return (
+ showExpanded && (
+
+
+
+ )
+ );
+ }, [ showExpanded ] );
+
+ const renderedOptions = useMemo( () => {
+ // Truncate options if > the limit + 5.
+ const optionCount = options.length;
+ const shouldTruncateOptions = optionCount > limit + 5;
+ return (
+
+ { options.map( ( option, index ) => (
+
+ = limit && { hidden: true } }
+ >
+
+
+
+ { shouldTruncateOptions &&
+ index === limit - 1 &&
+ renderedShowMore }
+
+ ) ) }
+ { shouldTruncateOptions && renderedShowLess }
+
+ );
+ }, [
+ options,
+ checked,
+ showExpanded,
+ limit,
+ onCheckboxChange,
+ renderedShowLess,
+ renderedShowMore,
+ ] );
+
+ const classes = classNames(
+ 'wc-block-checkbox-list',
+ {
+ 'is-loading': isLoading,
+ },
+ className
+ );
+
+ return (
+
+ { isLoading ? placeholder : renderedOptions }
+
+ );
+};
+
+CheckboxList.propTypes = {
+ onChange: PropTypes.func,
+ options: PropTypes.arrayOf(
+ PropTypes.shape( {
+ key: PropTypes.string.isRequired,
+ label: PropTypes.node.isRequired,
+ } )
+ ),
+ className: PropTypes.string,
+ isLoading: PropTypes.bool,
+ limit: PropTypes.number,
+};
+
+export default CheckboxList;
diff --git a/assets/js/base/components/checkbox-list/style.scss b/assets/js/base/components/checkbox-list/style.scss
new file mode 100644
index 00000000000..095aee8798e
--- /dev/null
+++ b/assets/js/base/components/checkbox-list/style.scss
@@ -0,0 +1,29 @@
+.editor-styles-wrapper .wc-block-checkbox-list,
+.wc-block-checkbox-list {
+ margin: 0;
+ padding: 0;
+ list-style: none outside;
+
+ li {
+ margin: 0 0 $gap-smallest;
+ padding: 0;
+ list-style: none outside;
+ }
+
+ li.show-more,
+ li.show-less {
+ button {
+ background: none;
+ border: none;
+ padding: 0;
+ text-decoration: underline;
+ cursor: pointer;
+ }
+ }
+
+ &.is-loading {
+ li {
+ @include placeholder();
+ }
+ }
+}
diff --git a/assets/js/base/hooks/use-collection-header.js b/assets/js/base/hooks/use-collection-header.js
index 3854336129c..4f82379d955 100644
--- a/assets/js/base/hooks/use-collection-header.js
+++ b/assets/js/base/hooks/use-collection-header.js
@@ -41,7 +41,12 @@ import { useShallowEqual } from './use-shallow-equal';
* loading (true) or not.
*/
export const useCollectionHeader = ( headerKey, options ) => {
- const { namespace, resourceName, resourceValues, query } = options;
+ const {
+ namespace,
+ resourceName,
+ resourceValues = [],
+ query = {},
+ } = options;
if ( ! namespace || ! resourceName ) {
throw new Error(
'The options object must have valid values for the namespace and ' +
@@ -61,7 +66,7 @@ export const useCollectionHeader = ( headerKey, options ) => {
resourceName,
currentQuery,
currentResourceValues,
- ].filter( ( item ) => typeof item !== 'undefined' );
+ ];
return {
value: store.getCollectionHeader( ...args ),
isLoading: store.hasFinishedResolution(
diff --git a/assets/js/base/hooks/use-collection.js b/assets/js/base/hooks/use-collection.js
index a630b587c3f..3fb4f91f73a 100644
--- a/assets/js/base/hooks/use-collection.js
+++ b/assets/js/base/hooks/use-collection.js
@@ -36,7 +36,12 @@ import { useShallowEqual } from './use-shallow-equal';
* loading (true) or not.
*/
export const useCollection = ( options ) => {
- const { namespace, resourceName, resourceValues, query } = options;
+ const {
+ namespace,
+ resourceName,
+ resourceValues = [],
+ query = {},
+ } = options;
if ( ! namespace || ! resourceName ) {
throw new Error(
'The options object must have valid values for the namespace and ' +
@@ -55,7 +60,7 @@ export const useCollection = ( options ) => {
resourceName,
currentQuery,
currentResourceValues,
- ].filter( ( item ) => typeof item !== 'undefined' );
+ ];
return {
results: store.getCollection( ...args ),
isLoading: ! store.hasFinishedResolution(
diff --git a/assets/js/base/hooks/use-query-state.js b/assets/js/base/hooks/use-query-state.js
index e734f2d2ec5..4259103531b 100644
--- a/assets/js/base/hooks/use-query-state.js
+++ b/assets/js/base/hooks/use-query-state.js
@@ -44,15 +44,20 @@ export const useQueryStateByContext = ( context ) => {
*
* @param {string} context What context to retrieve the query state for.
* @param {*} queryKey The specific query key to retrieve the value for.
+ * @param {*} defaultValue Default value if query does not exist.
*
* @return {*} Whatever value is set at the query state index using the
* provided context and query key.
*/
-export const useQueryStateByKey = ( context, queryKey ) => {
+export const useQueryStateByKey = (
+ context,
+ queryKey,
+ defaultValue
+) => {
const queryValue = useSelect(
( select ) => {
const store = select( storeKey );
- return store.getValueForQueryKey( context, queryKey, undefined );
+ return store.getValueForQueryKey( context, queryKey, defaultValue );
},
[ context, queryKey ]
);
diff --git a/assets/js/blocks/attribute-filter/block.js b/assets/js/blocks/attribute-filter/block.js
new file mode 100644
index 00000000000..d8cd7ec720a
--- /dev/null
+++ b/assets/js/blocks/attribute-filter/block.js
@@ -0,0 +1,187 @@
+/**
+ * External dependencies
+ */
+import {
+ useCollection,
+ useQueryStateByKey,
+ useQueryStateByContext,
+} from '@woocommerce/base-hooks';
+import {
+ useCallback,
+ Fragment,
+ useEffect,
+ useState,
+ useMemo,
+} from '@wordpress/element';
+import { sortBy } from 'lodash';
+import CheckboxList from '@woocommerce/base-components/checkbox-list';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import { getTaxonomyFromAttributeId } from '../../utils/attributes';
+
+/**
+ * Component displaying an attribute filter.
+ */
+const AttributeFilterBlock = ( { attributes } ) => {
+ const [ options, setOptions ] = useState( [] );
+ const [ checkedOptions, setCheckedOptions ] = useState( [] );
+ const { showCounts, attributeId, queryType } = attributes;
+ const taxonomy = getTaxonomyFromAttributeId( attributeId );
+
+ const [ queryState ] = useQueryStateByContext( 'product-grid' );
+ const [ productAttributes, setProductAttributes ] = useQueryStateByKey(
+ 'product-grid',
+ 'attributes',
+ []
+ );
+
+ const filteredCountsQueryState = useMemo( () => {
+ // If doing an "AND" query, we need to remove current taxonomy query so counts are not affected.
+ const modifiedQueryState =
+ queryType === 'or'
+ ? productAttributes.filter(
+ ( item ) => item.attribute !== taxonomy
+ )
+ : productAttributes;
+
+ // Take current query and remove paging args.
+ return {
+ ...queryState,
+ orderby: undefined,
+ order: undefined,
+ per_page: undefined,
+ page: undefined,
+ attributes: modifiedQueryState,
+ calculate_attribute_counts: [ taxonomy ],
+ };
+ }, [ queryState, taxonomy, queryType, productAttributes ] );
+
+ const {
+ results: attributeTerms,
+ isLoading: attributeTermsLoading,
+ } = useCollection( {
+ namespace: '/wc/store',
+ resourceName: 'products/attributes/terms',
+ resourceValues: [ attributeId ],
+ } );
+
+ const {
+ results: filteredCounts,
+ isLoading: filteredCountsLoading,
+ } = useCollection( {
+ namespace: '/wc/store',
+ resourceName: 'products/collection-data',
+ query: filteredCountsQueryState,
+ } );
+
+ const getLabel = useCallback(
+ ( name, count ) => {
+ return (
+
+ { name }
+ { showCounts && (
+
+ { count }
+
+ ) }
+
+ );
+ },
+ [ showCounts ]
+ );
+
+ const getFilteredTerm = useCallback(
+ ( id ) => {
+ if ( ! filteredCounts.attribute_counts ) {
+ return {};
+ }
+ return filteredCounts.attribute_counts.find(
+ ( { term } ) => term === id
+ );
+ },
+ [ filteredCounts ]
+ );
+
+ /**
+ * Compare intersection of all terms and filtered counts to get a list of options to display.
+ */
+ useEffect( () => {
+ // Do nothing until we have the attribute terms from the API.
+ if ( attributeTermsLoading || filteredCountsLoading ) {
+ return;
+ }
+
+ const newOptions = [];
+
+ attributeTerms.forEach( ( term ) => {
+ const filteredTerm = getFilteredTerm( term.id );
+ const isChecked = checkedOptions.includes( term.slug );
+ const inCollection = !! filteredTerm;
+
+ // If there is no match this term doesn't match the current product collection - only render if checked.
+ if ( ! inCollection && ! isChecked ) {
+ return;
+ }
+
+ const filteredCount = filteredTerm
+ ? filteredTerm.count
+ : term.count;
+ const count = ! inCollection && isChecked ? 0 : filteredCount;
+
+ newOptions.push( {
+ key: term.slug,
+ label: getLabel( term.name, count ),
+ } );
+ } );
+
+ setOptions( newOptions );
+ }, [
+ filteredCountsLoading,
+ attributeTerms,
+ attributeTermsLoading,
+ getFilteredTerm,
+ getLabel,
+ checkedOptions,
+ ] );
+
+ useEffect( () => {
+ const newProductAttributes = productAttributes.filter(
+ ( item ) => item.attribute !== taxonomy
+ );
+
+ if ( checkedOptions ) {
+ const updatedQuery = {
+ attribute: taxonomy,
+ operator: queryType === 'or' ? 'in' : 'and',
+ slug: checkedOptions,
+ };
+ newProductAttributes.push( updatedQuery );
+ }
+
+ setProductAttributes( sortBy( newProductAttributes, 'attribute' ) );
+ }, [ checkedOptions, taxonomy, productAttributes, queryType ] );
+
+ const onChange = useCallback( ( checked ) => {
+ setCheckedOptions( checked );
+ }, [] );
+
+ if ( ! taxonomy ) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default AttributeFilterBlock;
diff --git a/assets/js/blocks/attribute-filter/edit.js b/assets/js/blocks/attribute-filter/edit.js
new file mode 100644
index 00000000000..a6b4d0989c5
--- /dev/null
+++ b/assets/js/blocks/attribute-filter/edit.js
@@ -0,0 +1,296 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf, _n } from '@wordpress/i18n';
+import { Fragment, useState, useCallback } from '@wordpress/element';
+import { InspectorControls, BlockControls } from '@wordpress/editor';
+import {
+ Placeholder,
+ Disabled,
+ PanelBody,
+ ToggleControl,
+ Button,
+ Toolbar,
+ withSpokenMessages,
+} from '@wordpress/components';
+import Gridicon from 'gridicons';
+import { SearchListControl } from '@woocommerce/components';
+import { mapValues, toArray, sortBy } from 'lodash';
+import { ATTRIBUTES } from '@woocommerce/block-settings';
+import { getAdminLink } from '@woocommerce/navigation';
+
+/**
+ * Internal dependencies
+ */
+import Block from './block.js';
+import './editor.scss';
+import { IconExternal } from '../../components/icons';
+import ToggleButtonControl from '../../components/toggle-button-control';
+
+const Edit = ( { attributes, setAttributes, debouncedSpeak } ) => {
+ const [ isEditing, setIsEditing ] = useState( ! attributes.attributeId );
+
+ const getBlockControls = () => {
+ return (
+
+ setIsEditing( ! isEditing ),
+ isActive: isEditing,
+ },
+ ] }
+ />
+
+ );
+ };
+
+ const getInspectorControls = () => {
+ const { showCounts, queryType } = attributes;
+
+ return (
+
+
+
+ setAttributes( {
+ showCounts: ! showCounts,
+ } )
+ }
+ />
+
+
+
+ setAttributes( {
+ queryType: value,
+ } )
+ }
+ />
+
+
+ { renderAttributeControl() }
+
+
+ );
+ };
+
+ const noAttributesPlaceholder = () => (
+ }
+ label={ __(
+ 'Filter Products by Attribute',
+ 'woo-gutenberg-products-block'
+ ) }
+ instructions={ __(
+ 'Display a list of filters based on a chosen attribute.',
+ 'woo-gutenberg-products-block'
+ ) }
+ >
+
+ { __(
+ "Attributes are needed for filtering your products. You haven't created any products yet.",
+ 'woo-gutenberg-products-block'
+ ) }
+
+
+
+
+ );
+
+ const onDone = useCallback( () => {
+ setIsEditing( false );
+ debouncedSpeak(
+ __(
+ 'Showing attribute filter block preview.',
+ 'woo-gutenberg-products-block'
+ )
+ );
+ }, [] );
+
+ const onChange = useCallback( ( selected ) => {
+ setAttributes( {
+ attributeId: selected[ 0 ].id,
+ } );
+ }, [] );
+
+ const renderAttributeControl = () => {
+ const { attributeId } = attributes;
+
+ const messages = {
+ clear: __(
+ 'Clear selected attribute',
+ '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 a product attribute:',
+ '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'
+ ),
+ };
+
+ const list = sortBy(
+ toArray(
+ mapValues( ATTRIBUTES, ( item ) => {
+ return {
+ id: parseInt( item.attribute_id, 10 ),
+ name: item.attribute_label,
+ };
+ } )
+ ),
+ 'name'
+ );
+
+ return (
+ id === attributeId ) }
+ onChange={ onChange }
+ messages={ messages }
+ isSingle
+ />
+ );
+ };
+
+ const renderEditMode = () => {
+ return (
+ }
+ label={ __(
+ 'Filter Products by Attribute',
+ 'woo-gutenberg-products-block'
+ ) }
+ instructions={ __(
+ 'Display a list of filters based on a chosen attribute.',
+ 'woo-gutenberg-products-block'
+ ) }
+ >
+
+ { renderAttributeControl() }
+
+
+
+ );
+ };
+
+ return Object.keys( ATTRIBUTES ).length === 0 ? (
+ noAttributesPlaceholder()
+ ) : (
+
+ { getBlockControls() }
+ { getInspectorControls() }
+ { isEditing ? (
+ renderEditMode()
+ ) : (
+
+
+
+ ) }
+
+ );
+};
+
+export default withSpokenMessages( Edit );
diff --git a/assets/js/blocks/attribute-filter/editor.scss b/assets/js/blocks/attribute-filter/editor.scss
new file mode 100644
index 00000000000..c6d2bc48b40
--- /dev/null
+++ b/assets/js/blocks/attribute-filter/editor.scss
@@ -0,0 +1,42 @@
+.wc-block-attribute-filter {
+ .components-placeholder__instructions {
+ border-bottom: 1px solid #e0e2e6;
+ width: 100%;
+ padding-bottom: 1em;
+ margin-bottom: 2em;
+ }
+ .components-placeholder__label svg {
+ fill: currentColor;
+ margin-right: 1ch;
+ }
+ .components-placeholder__fieldset {
+ display: block; /* Disable flex box */
+
+ p {
+ font-size: 14px;
+ }
+ }
+ .woocommerce-search-list__search {
+ border-top: 0;
+ margin-top: 0;
+ padding-top: 0;
+ }
+ .wc-block-attribute-filter__add_attribute_button {
+ margin: 0 0 1em;
+ line-height: 24px;
+ vertical-align: middle;
+ height: auto;
+ font-size: 14px;
+ padding: 0.5em 1em;
+
+ svg {
+ fill: currentColor;
+ margin-left: 0.5ch;
+ vertical-align: middle;
+ }
+ }
+ .wc-block-attribute-filter__read_more_button {
+ display: block;
+ margin-bottom: 1em;
+ }
+}
diff --git a/assets/js/blocks/attribute-filter/frontend.js b/assets/js/blocks/attribute-filter/frontend.js
new file mode 100644
index 00000000000..3e23470393c
--- /dev/null
+++ b/assets/js/blocks/attribute-filter/frontend.js
@@ -0,0 +1,21 @@
+/**
+ * External dependencies
+ */
+import renderFrontend from '../../utils/render-frontend.js';
+
+/**
+ * Internal dependencies
+ */
+import Block from './block.js';
+
+const getProps = ( el ) => {
+ return {
+ attributes: {
+ attributeId: parseInt( el.dataset.attributeId || 0, 10 ),
+ showCounts: el.dataset.showCounts === 'true',
+ queryType: el.dataset.queryType,
+ },
+ };
+};
+
+renderFrontend( '.wp-block-woocommerce-attribute-filter', Block, getProps );
diff --git a/assets/js/blocks/attribute-filter/index.js b/assets/js/blocks/attribute-filter/index.js
new file mode 100644
index 00000000000..9155e0b71bb
--- /dev/null
+++ b/assets/js/blocks/attribute-filter/index.js
@@ -0,0 +1,63 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { registerBlockType } from '@wordpress/blocks';
+import Gridicon from 'gridicons';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit.js';
+
+registerBlockType( 'woocommerce/attribute-filter', {
+ title: __( 'Filter Products by Attribute', 'woo-gutenberg-products-block' ),
+ icon: {
+ src: ,
+ foreground: '#96588a',
+ },
+ category: 'woocommerce',
+ keywords: [ __( 'WooCommerce', 'woo-gutenberg-products-block' ) ],
+ description: __(
+ 'Display a list of filters based on a chosen product attribute.',
+ 'woo-gutenberg-products-block'
+ ),
+ supports: {
+ align: [ 'wide', 'full' ],
+ },
+ attributes: {
+ attributeId: {
+ type: 'number',
+ default: 0,
+ },
+ showCounts: {
+ type: 'boolean',
+ default: true,
+ },
+ queryType: {
+ type: 'string',
+ default: 'or',
+ },
+ },
+ edit,
+ /**
+ * Save the props to post content.
+ */
+ save( { attributes } ) {
+ const { showCounts, displayStyle, queryType, attributeId } = attributes;
+ const data = {
+ 'data-attribute-id': attributeId,
+ 'data-show-counts': showCounts,
+ 'data-display-style': displayStyle,
+ 'data-query-type': queryType,
+ };
+ return (
+
+
+
+ );
+ },
+} );
diff --git a/assets/js/blocks/attribute-filter/style.scss b/assets/js/blocks/attribute-filter/style.scss
new file mode 100644
index 00000000000..3d6c48245b0
--- /dev/null
+++ b/assets/js/blocks/attribute-filter/style.scss
@@ -0,0 +1,24 @@
+.wc-block-attribute-filter {
+ .wc-block-attribute-filter-list {
+ margin: 0 0 $gap-small;
+
+ li {
+ text-decoration: underline;
+
+ label,
+ input {
+ cursor: pointer;
+ }
+ }
+
+ .wc-block-attribute-filter-list-count {
+ float: right;
+ }
+ .wc-block-attribute-filter-list-count::before {
+ content: " (";
+ }
+ .wc-block-attribute-filter-list-count::after {
+ content: ")";
+ }
+ }
+}
diff --git a/assets/js/blocks/price-filter/edit.js b/assets/js/blocks/price-filter/edit.js
index 36571d98414..2c241ad16b2 100644
--- a/assets/js/blocks/price-filter/edit.js
+++ b/assets/js/blocks/price-filter/edit.js
@@ -12,7 +12,7 @@ import {
Button,
} from '@wordpress/components';
import { PRODUCT_COUNT } from '@woocommerce/block-settings';
-import { ADMIN_URL } from '@woocommerce/settings';
+import { getAdminLink } from '@woocommerce/navigation';
/**
* Internal dependencies
@@ -113,7 +113,7 @@ export default function( { attributes, setAttributes } ) {
className="wc-block-price-slider__add_product_button"
isDefault
isLarge
- href={ ADMIN_URL + 'post-new.php?post_type=product' }
+ href={ getAdminLink( 'post-new.php?post_type=product' ) }
>
{ __( 'Add new product', 'woo-gutenberg-products-block' ) +
' ' }
diff --git a/assets/js/blocks/products-by-attribute/block.js b/assets/js/blocks/products-by-attribute/block.js
index 4e9f554e61c..275d9794dc8 100644
--- a/assets/js/blocks/products-by-attribute/block.js
+++ b/assets/js/blocks/products-by-attribute/block.js
@@ -20,7 +20,7 @@ import Gridicon from 'gridicons';
import PropTypes from 'prop-types';
import GridContentControl from '@woocommerce/block-components/grid-content-control';
import GridLayoutControl from '@woocommerce/block-components/grid-layout-control';
-import ProductAttributeControl from '@woocommerce/block-components/product-attribute-control';
+import ProductAttributeTermControl from '@woocommerce/block-components/product-attribute-term-control';
import ProductOrderbyControl from '@woocommerce/block-components/product-orderby-control';
/**
@@ -70,7 +70,7 @@ class ProductsByAttributeBlock extends Component {
) }
initialOpen={ false }
>
- {
/* eslint-disable camelcase */
@@ -129,7 +129,7 @@ class ProductsByAttributeBlock extends Component {
'woo-gutenberg-products-block'
) }
-
{
/* eslint-disable camelcase */
diff --git a/assets/js/components/README.md b/assets/js/components/README.md
index c4bdf68910d..3aede893d1b 100644
--- a/assets/js/components/README.md
+++ b/assets/js/components/README.md
@@ -20,7 +20,7 @@ A pre-configured SelectControl for product orderby settings.
Display a preview for a given product.
-## `ProductAttributeControl`
+## `ProductAttributeTermControl`
A component using [`SearchListControl`](https://woocommerce.github.io/woocommerce-admin/#/components/packages/search-list-control) to show product attributes as selectable options. Only allows for selecting attribute terms from one attribute at a time (multiple terms can be selected).
diff --git a/assets/js/components/product-attribute-control/index.js b/assets/js/components/product-attribute-term-control/index.js
similarity index 96%
rename from assets/js/components/product-attribute-control/index.js
rename to assets/js/components/product-attribute-term-control/index.js
index fcebae3fce8..e5c4ec99cae 100644
--- a/assets/js/components/product-attribute-control/index.js
+++ b/assets/js/components/product-attribute-term-control/index.js
@@ -15,7 +15,7 @@ import ErrorMessage from '@woocommerce/block-components/error-placeholder/error-
*/
import './style.scss';
-const ProductAttributeControl = ( {
+const ProductAttributeTermControl = ( {
attributes,
error,
expandedAttribute,
@@ -186,7 +186,7 @@ const ProductAttributeControl = ( {
);
};
-ProductAttributeControl.propTypes = {
+ProductAttributeTermControl.propTypes = {
/**
* Callback to update the selected product attributes.
*/
@@ -213,8 +213,8 @@ ProductAttributeControl.propTypes = {
termsList: PropTypes.object,
};
-ProductAttributeControl.defaultProps = {
+ProductAttributeTermControl.defaultProps = {
operator: 'any',
};
-export default withAttributes( ProductAttributeControl );
+export default withAttributes( ProductAttributeTermControl );
diff --git a/assets/js/components/product-attribute-control/style.scss b/assets/js/components/product-attribute-term-control/style.scss
similarity index 100%
rename from assets/js/components/product-attribute-control/style.scss
rename to assets/js/components/product-attribute-term-control/style.scss
diff --git a/assets/js/settings/blocks/constants.js b/assets/js/settings/blocks/constants.js
index 0897469ccbf..2c01a3fd23c 100644
--- a/assets/js/settings/blocks/constants.js
+++ b/assets/js/settings/blocks/constants.js
@@ -21,3 +21,4 @@ export const HAS_PRODUCTS = getSetting( 'hasProducts', true );
export const HAS_TAGS = getSetting( 'hasTags', true );
export const HOME_URL = getSetting( 'homeUrl', '' );
export const PRODUCT_COUNT = getSetting( 'productCount', 0 );
+export const ATTRIBUTES = getSetting( 'attributes', [] );
diff --git a/assets/js/utils/attributes.js b/assets/js/utils/attributes.js
new file mode 100644
index 00000000000..84eaf7a86a8
--- /dev/null
+++ b/assets/js/utils/attributes.js
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import { find } from 'lodash';
+import { ATTRIBUTES } from '@woocommerce/block-settings';
+
+/**
+ * Get the ID of the first image attached to a product (the featured image).
+ *
+ * @param {number} attributeId The attribute ID.
+ * @return {string} The taxonomy name.
+ */
+export function getTaxonomyFromAttributeId( attributeId ) {
+ if ( ! attributeId ) {
+ return null;
+ }
+
+ const productAttribute = find( ATTRIBUTES, [
+ 'attribute_id',
+ attributeId.toString(),
+ ] );
+
+ return productAttribute.attribute_name
+ ? 'pa_' + productAttribute.attribute_name
+ : null;
+}
diff --git a/bin/webpack-helpers.js b/bin/webpack-helpers.js
index d21eb3c8189..a1d69e6d680 100644
--- a/bin/webpack-helpers.js
+++ b/bin/webpack-helpers.js
@@ -115,12 +115,14 @@ const mainEntry = {
'featured-category': './assets/js/blocks/featured-category/index.js',
'all-products': './assets/js/blocks/products/all-products/index.js',
'price-filter': './assets/js/blocks/price-filter/index.js',
+ 'attribute-filter': './assets/js/blocks/attribute-filter/index.js',
};
const frontEndEntry = {
reviews: './assets/js/blocks/reviews/frontend.js',
'all-products': './assets/js/blocks/products/all-products/frontend.js',
'price-filter': './assets/js/blocks/price-filter/frontend.js',
+ 'attribute-filter': './assets/js/blocks/attribute-filter/frontend.js',
};
const getEntryConfig = ( main = true, exclude = [] ) => {
diff --git a/src/Assets.php b/src/Assets.php
index 0aa92e721a3..89e7a67b898 100644
--- a/src/Assets.php
+++ b/src/Assets.php
@@ -67,6 +67,7 @@ public static function register_assets() {
self::register_script( 'wc-product-search', plugins_url( self::get_block_asset_build_path( 'product-search' ), __DIR__ ), $block_dependencies );
self::register_script( 'wc-all-products', plugins_url( self::get_block_asset_build_path( 'all-products' ), __DIR__ ), $block_dependencies );
self::register_script( 'wc-price-filter', plugins_url( self::get_block_asset_build_path( 'price-filter' ), __DIR__ ), $block_dependencies );
+ self::register_script( 'wc-attribute-filter', plugins_url( self::get_block_asset_build_path( 'attribute-filter' ), __DIR__ ), $block_dependencies );
}
/**
@@ -115,6 +116,7 @@ public static function get_wc_block_data( $settings ) {
'showAvatars' => '1' === get_option( 'show_avatars' ),
'enableReviewRating' => 'yes' === get_option( 'woocommerce_enable_review_rating' ),
'productCount' => array_sum( (array) $product_counts ),
+ 'attributes' => wc_get_attribute_taxonomies(),
]
);
}
diff --git a/src/BlockTypes/AttributeFilter.php b/src/BlockTypes/AttributeFilter.php
new file mode 100644
index 00000000000..5b1d37ddebe
--- /dev/null
+++ b/src/BlockTypes/AttributeFilter.php
@@ -0,0 +1,51 @@
+namespace . '/' . $this->block_name,
+ array(
+ 'render_callback' => array( $this, 'render' ),
+ 'editor_script' => 'wc-' . $this->block_name,
+ 'editor_style' => 'wc-block-editor',
+ 'style' => 'wc-block-style',
+ 'script' => 'wc-' . $this->block_name . '-frontend',
+ )
+ );
+ }
+
+ /**
+ * Append frontend scripts when rendering the Product Categories List block.
+ *
+ * @param array $attributes Block attributes. Default empty array.
+ * @param string $content Block content. Default empty string.
+ * @return string Rendered block type output.
+ */
+ public function render( $attributes = array(), $content = '' ) {
+ \Automattic\WooCommerce\Blocks\Assets::register_block_script( $this->block_name . '-frontend' );
+ return $content;
+ }
+}
diff --git a/src/Library.php b/src/Library.php
index b239cbec3fe..f816d370c68 100644
--- a/src/Library.php
+++ b/src/Library.php
@@ -48,6 +48,7 @@ public static function register_blocks() {
if ( version_compare( $wp_version, '5.2', '>' ) ) {
$blocks[] = 'AllProducts';
$blocks[] = 'PriceFilter';
+ $blocks[] = 'AttributeFilter';
}
foreach ( $blocks as $class ) {
$class = __NAMESPACE__ . '\\BlockTypes\\' . $class;
diff --git a/src/RestApi.php b/src/RestApi.php
index f7addae3520..691dd1db4cc 100644
--- a/src/RestApi.php
+++ b/src/RestApi.php
@@ -76,6 +76,8 @@ protected static function get_controllers() {
'store-cart-items' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\CartItems',
'store-products' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\Products',
'store-product-collection-data' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\ProductCollectionData',
+ 'store-product-attributes' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\ProductAttributes',
+ 'store-product-attribute-terms' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\ProductAttributeTerms',
];
}
}
diff --git a/src/RestApi/StoreApi/Controllers/ProductAttributeTerms.php b/src/RestApi/StoreApi/Controllers/ProductAttributeTerms.php
new file mode 100644
index 00000000000..0683f898de2
--- /dev/null
+++ b/src/RestApi/StoreApi/Controllers/ProductAttributeTerms.php
@@ -0,0 +1,162 @@
+[\d]+)/terms';
+
+ /**
+ * Schema class instance.
+ *
+ * @var TermSchema
+ */
+ protected $schema;
+
+ /**
+ * Query class instance.
+ *
+ * @var TermQuery
+ */
+ protected $term_query;
+
+ /**
+ * Setup API class.
+ */
+ public function __construct() {
+ $this->schema = new TermSchema();
+ $this->term_query = new TermQuery();
+ }
+
+ /**
+ * Register the routes.
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ 'args' => array(
+ 'attribute_id' => array(
+ 'description' => __( 'Unique identifier for the attribute.', 'woo-gutenberg-products-block' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => RestServer::READABLE,
+ 'callback' => [ $this, 'get_items' ],
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Item schema.
+ *
+ * @return array
+ */
+ public function get_item_schema() {
+ return $this->schema->get_item_schema();
+ }
+
+ /**
+ * Prepare a single item for response.
+ *
+ * @param \WP_Term $item Term object.
+ * @param \WP_REST_Request $request Request object.
+ * @return \WP_REST_Response $response Response data.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ return rest_ensure_response( $this->schema->get_item_response( $item ) );
+ }
+
+ /**
+ * Get a collection of attribute terms.
+ *
+ * @param \WP_REST_Request $request Full details about the request.
+ * @return RestError|\WP_REST_Response
+ */
+ public function get_items( $request ) {
+ $attribute = wc_get_attribute( $request['attribute_id'] );
+
+ if ( ! $attribute || ! taxonomy_exists( $attribute->slug ) ) {
+ return new \WP_Error( 'woocommerce_rest_taxonomy_invalid', __( 'Attribute does not exist.', 'woo-gutenberg-products-block' ), array( 'status' => 404 ) );
+ }
+
+ $request['taxonomy'] = $attribute->slug;
+ $objects = $this->term_query->get_objects( $request );
+
+ foreach ( $objects as $object ) {
+ $data = $this->prepare_item_for_response( $object, $request );
+ $return[] = $this->prepare_response_for_collection( $data );
+ }
+
+ return rest_ensure_response( $return );
+ }
+
+ /**
+ * Get the query params for collections of attributes.
+ *
+ * @return array
+ */
+ public function get_collection_params() {
+ $params = array();
+ $params['context'] = $this->get_context_param();
+ $params['context']['default'] = 'view';
+
+ $params['order'] = array(
+ 'description' => __( 'Order sort attribute ascending or descending.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'default' => 'asc',
+ 'enum' => array( 'asc', 'desc' ),
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
+ $params['orderby'] = array(
+ 'description' => __( 'Sort collection by object attribute.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'default' => 'name',
+ 'enum' => array(
+ 'name',
+ 'slug',
+ 'count',
+ ),
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
+ return $params;
+ }
+}
diff --git a/src/RestApi/StoreApi/Controllers/ProductAttributes.php b/src/RestApi/StoreApi/Controllers/ProductAttributes.php
new file mode 100644
index 00000000000..e89593fc7fc
--- /dev/null
+++ b/src/RestApi/StoreApi/Controllers/ProductAttributes.php
@@ -0,0 +1,164 @@
+schema = new ProductAttributeSchema();
+ }
+
+ /**
+ * Register the routes.
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => RestServer::READABLE,
+ 'callback' => [ $this, 'get_items' ],
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P[\d]+)',
+ array(
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the resource.', 'woo-gutenberg-products-block' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => RestServer::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'args' => array(
+ 'context' => $this->get_context_param(
+ array(
+ 'default' => 'view',
+ )
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Item schema.
+ *
+ * @return array
+ */
+ public function get_item_schema() {
+ return $this->schema->get_item_schema();
+ }
+
+ /**
+ * Prepare a single item for response.
+ *
+ * @param object $item Attribute object.
+ * @param \WP_REST_Request $request Request object.
+ * @return \WP_REST_Response $response Response data.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ return rest_ensure_response( $this->schema->get_item_response( $item ) );
+ }
+
+ /**
+ * Get a single item.
+ *
+ * @param \WP_REST_Request $request Full details about the request.
+ * @return RestError|\WP_REST_Response
+ */
+ public function get_item( $request ) {
+ $object = wc_get_attribute( (int) $request['id'] );
+
+ if ( ! $object || 0 === $object->id ) {
+ return new RestError( 'woocommerce_rest_attribute_invalid_id', __( 'Invalid attribute ID.', 'woo-gutenberg-products-block' ), array( 'status' => 404 ) );
+ }
+
+ $data = $this->prepare_item_for_response( $object, $request );
+ $response = rest_ensure_response( $data );
+
+ return $response;
+ }
+
+ /**
+ * Get a collection of attributes.
+ *
+ * @param \WP_REST_Request $request Full details about the request.
+ * @return RestError|\WP_REST_Response
+ */
+ public function get_items( $request ) {
+ $ids = wc_get_attribute_taxonomy_ids();
+ $return = [];
+
+ foreach ( $ids as $id ) {
+ $object = wc_get_attribute( $id );
+ $data = $this->prepare_item_for_response( $object, $request );
+ $return[] = $this->prepare_response_for_collection( $data );
+ }
+
+ return rest_ensure_response( $return );
+ }
+
+ /**
+ * Get the query params for collections of attributes.
+ *
+ * @return array
+ */
+ public function get_collection_params() {
+ $params = array();
+ $params['context'] = $this->get_context_param();
+ $params['context']['default'] = 'view';
+ return $params;
+ }
+}
diff --git a/src/RestApi/StoreApi/Controllers/Products.php b/src/RestApi/StoreApi/Controllers/Products.php
index 5aef79be551..f067ebf7e88 100644
--- a/src/RestApi/StoreApi/Controllers/Products.php
+++ b/src/RestApi/StoreApi/Controllers/Products.php
@@ -358,6 +358,15 @@ public function get_collection_params() {
'validate_callback' => 'rest_validate_request_arg',
);
+ $params['category_operator'] = array(
+ 'description' => __( 'Operator to compare product category terms.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'enum' => [ 'in', 'not in', 'and' ],
+ 'default' => 'in',
+ 'sanitize_callback' => 'sanitize_key',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
$params['tag'] = array(
'description' => __( 'Limit result set to products assigned a specific tag ID.', 'woo-gutenberg-products-block' ),
'type' => 'string',
@@ -365,6 +374,15 @@ public function get_collection_params() {
'validate_callback' => 'rest_validate_request_arg',
);
+ $params['tag_operator'] = array(
+ 'description' => __( 'Operator to compare product tags.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'enum' => [ 'in', 'not in', 'and' ],
+ 'default' => 'in',
+ 'sanitize_callback' => 'sanitize_key',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
$params['on_sale'] = array(
'description' => __( 'Limit result set to products on sale.', 'woo-gutenberg-products-block' ),
'type' => 'boolean',
@@ -394,33 +412,6 @@ public function get_collection_params() {
'validate_callback' => 'rest_validate_request_arg',
);
- $params['category_operator'] = array(
- 'description' => __( 'Operator to compare product category terms.', 'woo-gutenberg-products-block' ),
- 'type' => 'string',
- 'enum' => array( 'in', 'not_in', 'and' ),
- 'default' => 'in',
- 'sanitize_callback' => 'sanitize_key',
- 'validate_callback' => 'rest_validate_request_arg',
- );
-
- $params['tag_operator'] = array(
- 'description' => __( 'Operator to compare product tags.', 'woo-gutenberg-products-block' ),
- 'type' => 'string',
- 'enum' => array( 'in', 'not_in', 'and' ),
- 'default' => 'in',
- 'sanitize_callback' => 'sanitize_key',
- 'validate_callback' => 'rest_validate_request_arg',
- );
-
- $params['attribute_operator'] = array(
- 'description' => __( 'Operator to compare product attribute terms.', 'woo-gutenberg-products-block' ),
- 'type' => 'string',
- 'enum' => array( 'in', 'not_in', 'and' ),
- 'default' => 'in',
- 'sanitize_callback' => 'sanitize_key',
- 'validate_callback' => 'rest_validate_request_arg',
- );
-
$params['attributes'] = array(
'description' => __( 'Limit result set to products with selected global attributes.', 'woo-gutenberg-products-block' ),
'type' => 'array',
@@ -433,13 +424,14 @@ public function get_collection_params() {
'sanitize_callback' => 'wc_sanitize_taxonomy_name',
),
'term_id' => array(
- 'description' => __( 'Attribute term ID.', 'woo-gutenberg-products-block' ),
+ 'description' => __( 'List of attribute term IDs.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
),
'slug' => array(
- 'description' => __( 'Comma separatede list of attribute slug(s). If a term ID is provided, this will be ignored.', 'woo-gutenberg-products-block' ),
- 'type' => 'string',
+ 'description' => __( 'List of attribute slug(s). If a term ID is provided, this will be ignored.', 'woo-gutenberg-products-block' ),
+ 'type' => 'array',
+ 'sanitize_callback' => 'wp_parse_slug_list',
),
'operator' => array(
'description' => __( 'Operator to compare product attribute terms.', 'woo-gutenberg-products-block' ),
@@ -451,6 +443,15 @@ public function get_collection_params() {
'default' => array(),
);
+ $params['attribute_relation'] = array(
+ 'description' => __( 'The logical relationship between attributes when filtering across multiple at once.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'enum' => [ 'in', 'and' ],
+ 'default' => 'and',
+ 'sanitize_callback' => 'sanitize_key',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
$params['catalog_visibility'] = array(
'description' => __( 'Determines if hidden or visible catalog products are shown.', 'woo-gutenberg-products-block' ),
'type' => 'string',
diff --git a/src/RestApi/StoreApi/README.md b/src/RestApi/StoreApi/README.md
index 5950cf22e09..71a9fabadec 100644
--- a/src/RestApi/StoreApi/README.md
+++ b/src/RestApi/StoreApi/README.md
@@ -84,12 +84,14 @@ Additional pagination headers are also sent back.
Available resources in the Store API include:
-| Resource | Available endpoints |
-| :--------------------------------------------------------- | :----------------------------------- |
-| [`Product Collection Data`](#products-collection-data-api) | `/wc/store/products/collection-data` |
-| [`Products`](#products-api) | `/wc/store/products` |
-| [`Cart`](#cart-api) | `/wc/store/cart` |
-| [`Cart Items`](#cart-items-api) | `/wc/store/cart/items` |
+| Resource | Available endpoints |
+| :--------------------------------------------------------- | :-------------------------------------- |
+| [`Product Collection Data`](#products-collection-data-api) | `/wc/store/products/collection-data` |
+| [`Products`](#products-api) | `/wc/store/products` |
+| [`Cart`](#cart-api) | `/wc/store/cart` |
+| [`Cart Items`](#cart-items-api) | `/wc/store/cart/items` |
+| [`Product Attributes`](#product-attributes-api) | `/wc/store/products/attributes` |
+| [`Product Attribute Terms`](#product-attribute-terms-api) | `/wc/store/products/attributes/1/terms` |
## Product Collection Data API
@@ -551,3 +553,103 @@ Example response:
```json
[]
```
+
+## Product Attributes API
+
+```http
+GET /products/attributes
+```
+
+There are no parameters required for this endpoint.
+
+```http
+curl "https://example-store.com/wp-json/wc/store/products/attributes"
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 1,
+ "name": "Color",
+ "slug": "pa_color",
+ "type": "select",
+ "order": "menu_order",
+ "has_archives": false
+ },
+ {
+ "id": 2,
+ "name": "Size",
+ "slug": "pa_size",
+ "type": "select",
+ "order": "menu_order",
+ "has_archives": false
+ }
+]
+```
+
+### Single attribute
+
+Get a single attribute taxonomy.
+
+```http
+GET /products/attributes/:id
+```
+
+| Attribute | Type | Required | Description |
+| :-------- | :------ | :------: | :----------------------------------- |
+| `id` | integer | Yes | The ID of the attribute to retrieve. |
+
+```http
+curl "https://example-store.com/wp-json/wc/store/products/attributes/1"
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "name": "Color",
+ "slug": "pa_color",
+ "type": "select",
+ "order": "menu_order",
+ "has_archives": false
+}
+```
+
+## Product Attribute Terms API
+
+```http
+GET /products/attributes/:id/terms
+GET /products/attributes/:id/terms&orderby=slug
+```
+
+| Attribute | Type | Required | Description |
+| :-------- | :------ | :------: | :---------------------------------------------------------------------------- |
+| `id` | integer | Yes | The ID of the attribute to retrieve terms for. |
+| `order` | string | no | Order ascending or descending. Allowed values: `asc`, `desc` |
+| `orderby` | string | no | Sort collection by object attribute. Allowed values: `name`, `slug`, `count`. |
+
+```http
+curl "https://example-store.com/wp-json/wc/store/products/attributes/1/terms"
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 22,
+ "name": "Blue",
+ "slug": "blue",
+ "count": 5
+ },
+ {
+ "id": 48,
+ "name": "Burgundy",
+ "slug": "burgundy",
+ "count": 1
+ }
+]
+```
diff --git a/src/RestApi/StoreApi/Schemas/ProductAttributeSchema.php b/src/RestApi/StoreApi/Schemas/ProductAttributeSchema.php
new file mode 100644
index 00000000000..10390087a1e
--- /dev/null
+++ b/src/RestApi/StoreApi/Schemas/ProductAttributeSchema.php
@@ -0,0 +1,87 @@
+ array(
+ 'description' => __( 'Unique identifier for the resource.', 'woo-gutenberg-products-block' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'Attribute name.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'slug' => array(
+ 'description' => __( 'String based identifier for the attribute, and its WordPress taxonomy.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'type' => array(
+ 'description' => __( 'Attribute type.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'order' => array(
+ 'description' => __( 'How terms in this attribute are sorted by default.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'has_archives' => array(
+ 'description' => __( 'If this attribute has term archive pages.', 'woo-gutenberg-products-block' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ];
+ }
+
+ /**
+ * Convert an attribute object into an object suitable for the response.
+ *
+ * @param object $attribute Attribute object.
+ * @return array
+ */
+ public function get_item_response( $attribute ) {
+ return [
+ 'id' => (int) $attribute->id,
+ 'name' => $attribute->name,
+ 'slug' => $attribute->slug,
+ 'type' => $attribute->type,
+ 'order' => $attribute->order_by,
+ 'has_archives' => $attribute->has_archives,
+ ];
+ }
+}
diff --git a/src/RestApi/StoreApi/Schemas/ProductSchema.php b/src/RestApi/StoreApi/Schemas/ProductSchema.php
index 8f2c6caa6fc..cb6ebb8dd75 100644
--- a/src/RestApi/StoreApi/Schemas/ProductSchema.php
+++ b/src/RestApi/StoreApi/Schemas/ProductSchema.php
@@ -25,7 +25,7 @@ class ProductSchema extends AbstractSchema {
protected $title = 'product';
/**
- * Cart schema properties.
+ * Product schema properties.
*
* @return array
*/
diff --git a/src/RestApi/StoreApi/Schemas/TermSchema.php b/src/RestApi/StoreApi/Schemas/TermSchema.php
new file mode 100644
index 00000000000..713316dfd12
--- /dev/null
+++ b/src/RestApi/StoreApi/Schemas/TermSchema.php
@@ -0,0 +1,79 @@
+ array(
+ 'description' => __( 'Unique identifier for the resource.', 'woo-gutenberg-products-block' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'Term name.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'slug' => array(
+ 'description' => __( 'String based identifier for the term.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'description' => __( 'Term description.', 'woo-gutenberg-products-block' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'count' => array(
+ 'description' => __( 'Number of objects (posts of any type) assigned to the term.', 'woo-gutenberg-products-block' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ];
+ }
+
+ /**
+ * Convert a term object into an object suitable for the response.
+ *
+ * @param \WP_Term $term Term object.
+ * @return array
+ */
+ public function get_item_response( $term ) {
+ return [
+ 'id' => (int) $term->term_id,
+ 'name' => $term->name,
+ 'slug' => $term->slug,
+ 'count' => (int) $term->count,
+ ];
+ }
+}
diff --git a/src/RestApi/StoreApi/Utilities/ProductFiltering.php b/src/RestApi/StoreApi/Utilities/ProductFiltering.php
index c4b286b97e7..826948e848d 100644
--- a/src/RestApi/StoreApi/Utilities/ProductFiltering.php
+++ b/src/RestApi/StoreApi/Utilities/ProductFiltering.php
@@ -58,10 +58,10 @@ public function get_filtered_price( $request ) {
* Get attribute counts for the current products.
*
* @param \WP_REST_Request $request The request object.
- * @param array $attribute_names Attributes to count.
+ * @param array $attributes Attributes to count, either names or ids.
* @return array termId=>count pairs.
*/
- public function get_attribute_counts( $request, $attribute_names = [] ) {
+ public function get_attribute_counts( $request, $attributes = [] ) {
global $wpdb;
// Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products.
@@ -80,7 +80,11 @@ public function get_attribute_counts( $request, $attribute_names = [] ) {
remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
remove_filter( 'posts_pre_query', '__return_empty_array' );
- $attributes_to_count = array_map( 'wc_sanitize_taxonomy_name', $attribute_names );
+ if ( count( $attributes ) === count( array_filter( $attributes, 'is_numeric' ) ) ) {
+ $attributes = array_map( 'wc_attribute_taxonomy_name_by_id', wp_parse_id_list( $attributes ) );
+ }
+
+ $attributes_to_count = array_map( 'wc_sanitize_taxonomy_name', $attributes );
$attributes_to_count_sql = 'AND term_taxonomy.taxonomy IN ("' . implode( '","', $attributes_to_count ) . '")';
$attribute_count_sql = "
SELECT COUNT( DISTINCT posts.ID ) as term_count, terms.term_id as term_count_id
diff --git a/src/RestApi/StoreApi/Utilities/ProductQuery.php b/src/RestApi/StoreApi/Utilities/ProductQuery.php
index f5555b1a116..a22fda0a083 100644
--- a/src/RestApi/StoreApi/Utilities/ProductQuery.php
+++ b/src/RestApi/StoreApi/Utilities/ProductQuery.php
@@ -76,6 +76,12 @@ public function prepare_objects_query( $request ) {
// tag, shipping class, and attribute.
$tax_query = array();
+ $operator_mapping = array(
+ 'in' => 'IN',
+ 'not_in' => 'NOT IN',
+ 'and' => 'AND',
+ );
+
// Map between taxonomy name and arg's key.
$taxonomies = array(
'product_cat' => 'category',
@@ -85,10 +91,12 @@ public function prepare_objects_query( $request ) {
// Set tax_query for each passed arg.
foreach ( $taxonomies as $taxonomy => $key ) {
if ( ! empty( $request[ $key ] ) ) {
+ $operator = $request->get_param( $key . '_operator' ) && isset( $operator_mapping[ $request->get_param( $key . '_operator' ) ] ) ? $operator_mapping[ $request->get_param( $key . '_operator' ) ] : 'IN';
$tax_query[] = array(
'taxonomy' => $taxonomy,
'field' => 'term_id',
'terms' => $request[ $key ],
+ 'operator' => $operator,
);
}
}
@@ -104,19 +112,33 @@ public function prepare_objects_query( $request ) {
// Filter by attributes.
if ( ! empty( $request['attributes'] ) ) {
+ $att_queries = [];
+
foreach ( $request['attributes'] as $attribute ) {
if ( empty( $attribute['term_id'] ) && empty( $attribute['slug'] ) ) {
continue;
}
if ( in_array( $attribute['attribute'], wc_get_attribute_taxonomy_names(), true ) ) {
- $tax_query[] = array(
+ $operator = isset( $attribute['operator'], $operator_mapping[ $attribute['operator'] ] ) ? $operator_mapping[ $attribute['operator'] ] : 'IN';
+ $att_queries[] = array(
'taxonomy' => $attribute['attribute'],
'field' => ! empty( $attribute['term_id'] ) ? 'term_id' : 'slug',
'terms' => ! empty( $attribute['term_id'] ) ? $attribute['term_id'] : $attribute['slug'],
- 'operator' => isset( $attribute['operator'] ) ? $attribute['operator'] : 'IN',
+ 'operator' => $operator,
);
}
}
+
+ if ( 1 < count( $att_queries ) ) {
+ // Add relation arg when using multiple attributes.
+ $relation = $request->get_param( 'attribute_relation' ) && isset( $operator_mapping[ $request->get_param( 'attribute_relation' ) ] ) ? $operator_mapping[ $request->get_param( 'attribute_relation' ) ] : 'IN';
+ $tax_query[] = array(
+ 'relation' => $relation,
+ $att_queries,
+ );
+ } else {
+ $tax_query = array_merge( $tax_query, $att_queries );
+ }
}
// Build tax_query if taxonomies are set.
@@ -149,37 +171,6 @@ public function prepare_objects_query( $request ) {
$args[ $on_sale_key ] += $on_sale_ids;
}
- $operator_mapping = array(
- 'in' => 'IN',
- 'not_in' => 'NOT IN',
- 'and' => 'AND',
- );
-
- if ( isset( $args['tax_query'] ) ) {
- $category_operator = $request->get_param( 'category_operator' );
- $tag_operator = $request->get_param( 'tag_operator' );
- $attribute_operator = $request->get_param( 'attribute_operator' );
-
- foreach ( $args['tax_query'] as $i => $tax_query ) {
- if ( $category_operator && 'product_cat' === $tax_query['taxonomy'] ) {
- $operator = isset( $operator_mapping[ $category_operator ] ) ? $operator_mapping[ $category_operator ] : 'IN';
-
- $args['tax_query'][ $i ]['operator'] = $operator;
- $args['tax_query'][ $i ]['include_children'] = 'AND' === $operator ? false : true;
- }
- if ( 'product_tag' === $tax_query['taxonomy'] ) {
- $operator = isset( $operator_mapping[ $tag_operator ] ) ? $operator_mapping[ $tag_operator ] : 'IN';
-
- $args['tax_query'][ $i ]['operator'] = $operator;
- }
- if ( in_array( $tax_query['taxonomy'], wc_get_attribute_taxonomy_names(), true ) ) {
- $operator = isset( $operator_mapping[ $attribute_operator ] ) ? $operator_mapping[ $attribute_operator ] : 'IN';
-
- $args['tax_query'][ $i ]['operator'] = $operator;
- }
- }
- }
-
$catalog_visibility = $request->get_param( 'catalog_visibility' );
$rating = $request->get_param( 'rating' );
$visibility_options = wc_get_product_visibility_options();
diff --git a/src/RestApi/StoreApi/Utilities/TermQuery.php b/src/RestApi/StoreApi/Utilities/TermQuery.php
new file mode 100644
index 00000000000..256b67cf0f4
--- /dev/null
+++ b/src/RestApi/StoreApi/Utilities/TermQuery.php
@@ -0,0 +1,44 @@
+prepare_objects_query( $request );
+ $query = new \WP_Term_Query();
+
+ return $query->query( $query_args );
+ }
+}
diff --git a/tests/php/RestApi/StoreApi/Controllers/Products.php b/tests/php/RestApi/StoreApi/Controllers/Products.php
index 88f3a724e5a..f2243fc861d 100644
--- a/tests/php/RestApi/StoreApi/Controllers/Products.php
+++ b/tests/php/RestApi/StoreApi/Controllers/Products.php
@@ -161,7 +161,7 @@ public function test_get_collection_params() {
$this->assertArrayHasKey( 'stock_status', $params );
$this->assertArrayHasKey( 'category_operator', $params );
$this->assertArrayHasKey( 'tag_operator', $params );
- $this->assertArrayHasKey( 'attribute_operator', $params );
+ $this->assertArrayHasKey( 'attribute_relation', $params );
$this->assertArrayHasKey( 'attributes', $params );
$this->assertArrayHasKey( 'catalog_visibility', $params );
$this->assertArrayHasKey( 'rating', $params );
diff --git a/webpack.config.js b/webpack.config.js
index b7e443e246a..d4f334470b0 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -106,7 +106,7 @@ const LegacyBlocksConfig = {
getAlias( { pathPart: 'legacy' } )
),
],
- exclude: [ 'all-products', 'price-filter' ],
+ exclude: [ 'all-products', 'price-filter', 'attribute-filter' ],
} ),
};
@@ -122,7 +122,7 @@ const LegacyFrontendBlocksConfig = {
getAlias( { pathPart: 'legacy' } )
),
],
- exclude: [ 'all-products', 'price-filter' ],
+ exclude: [ 'all-products', 'price-filter', 'attribute-filter' ],
} ),
};