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 ( + + ); +}; + +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' ], } ), };