Add Stock Status setting to Product Query Block (https://github.com/woocommerce/woocommerce-blocks/pull/7397)

* Add Stock Status to Product Query block filters

Creates a new Tools Panel called “Product filters” where we can neatly
organize our product specific settings. Eventually, this panel could be
merged with the core “Filters” panel; however, at the time of this
commit, this is impossible (see WordPress/gutenbergwoocommerce/woocommerce-blocks#43684 for a PoC).

Also moved the “On Sale” setting under this newly created panel.

* Add `resetAll` callback for the new Tools Panel

Tools Panels come with a “Reset All” functionality, that's supposed to
return all the settings to their original state.

In our case, things are a bit more complicated, as the original state
is dependant on the current variation, so it can't be hard-coded like it
is on the core block.
This commit is contained in:
Lucio Giannotta 2022-10-27 19:40:10 +02:00 committed by GitHub
parent 0b6d76ce2d
commit af557b0281
8 changed files with 232 additions and 54 deletions

View File

@ -1,6 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import { getSetting } from '@woocommerce/settings';
import type { InnerBlockTemplate } from '@wordpress/blocks'; import type { InnerBlockTemplate } from '@wordpress/blocks';
/** /**
@ -8,15 +9,36 @@ import type { InnerBlockTemplate } from '@wordpress/blocks';
*/ */
import { QueryBlockAttributes } from './types'; import { QueryBlockAttributes } from './types';
/**
* Returns an object without a key.
*/
function objectOmit< T, K extends keyof T >( obj: T, key: K ) {
const { [ key ]: omit, ...rest } = obj;
return rest;
}
export const QUERY_LOOP_ID = 'core/query';
export const DEFAULT_CORE_ALLOWED_CONTROLS = [ 'order', 'taxQuery', 'search' ]; export const DEFAULT_CORE_ALLOWED_CONTROLS = [ 'order', 'taxQuery', 'search' ];
export const ALL_PRODUCT_QUERY_CONTROLS = [ 'onSale' ]; export const ALL_PRODUCT_QUERY_CONTROLS = [ 'onSale', 'stockStatus' ];
export const DEFAULT_ALLOWED_CONTROLS = [ export const DEFAULT_ALLOWED_CONTROLS = [
...DEFAULT_CORE_ALLOWED_CONTROLS, ...DEFAULT_CORE_ALLOWED_CONTROLS,
...ALL_PRODUCT_QUERY_CONTROLS, ...ALL_PRODUCT_QUERY_CONTROLS,
]; ];
export const STOCK_STATUS_OPTIONS = getSetting< Record< string, string > >(
'stockStatusOptions',
[]
);
const GLOBAL_HIDE_OUT_OF_STOCK = getSetting< boolean >(
'hideOutOfStockItems',
false
);
export const QUERY_DEFAULT_ATTRIBUTES: QueryBlockAttributes = { export const QUERY_DEFAULT_ATTRIBUTES: QueryBlockAttributes = {
allowedControls: DEFAULT_ALLOWED_CONTROLS, allowedControls: DEFAULT_ALLOWED_CONTROLS,
displayLayout: { displayLayout: {
@ -35,6 +57,9 @@ export const QUERY_DEFAULT_ATTRIBUTES: QueryBlockAttributes = {
exclude: [], exclude: [],
sticky: '', sticky: '',
inherit: false, inherit: false,
__woocommerceStockStatus: GLOBAL_HIDE_OUT_OF_STOCK
? Object.keys( objectOmit( STOCK_STATUS_OPTIONS, 'outofstock' ) )
: Object.keys( STOCK_STATUS_OPTIONS ),
}, },
}; };

View File

@ -7,6 +7,7 @@ import { addFilter } from '@wordpress/hooks';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { QUERY_LOOP_ID } from './constants';
import './inspector-controls'; import './inspector-controls';
import './variations/product-query'; import './variations/product-query';
import './variations/products-on-sale'; import './variations/products-on-sale';
@ -15,7 +16,7 @@ function registerProductQueryVariationAttributes(
props: Block, props: Block,
blockName: string blockName: string
) { ) {
if ( blockName === 'core/query' ) { if ( blockName === QUERY_LOOP_ID ) {
// Gracefully handle if settings.attributes is undefined. // Gracefully handle if settings.attributes is undefined.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- We need this because `attributes` is marked as `readonly` // @ts-ignore -- We need this because `attributes` is marked as `readonly`

View File

@ -1,56 +1,181 @@
/** /**
* External dependencies * External dependencies
*/ */
import { ElementType } from 'react';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { InspectorControls } from '@wordpress/block-editor'; import { InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ToggleControl } from '@wordpress/components'; import { useSelect } from '@wordpress/data';
import { addFilter } from '@wordpress/hooks'; import { addFilter } from '@wordpress/hooks';
import { EditorBlock } from '@woocommerce/types'; import { EditorBlock } from '@woocommerce/types';
import { ElementType } from 'react'; import {
FormTokenField,
ToggleControl,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToolsPanel as ToolsPanel,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { ProductQueryBlock } from './types'; import {
ProductQueryArguments,
ProductQueryBlock,
QueryBlockAttributes,
} from './types';
import { import {
isWooQueryBlockVariation, isWooQueryBlockVariation,
setCustomQueryAttribute, setCustomQueryAttribute,
useAllowedControls, useAllowedControls,
} from './utils'; } from './utils';
import {
ALL_PRODUCT_QUERY_CONTROLS,
QUERY_LOOP_ID,
STOCK_STATUS_OPTIONS,
} from './constants';
const NAMESPACED_CONTROLS = ALL_PRODUCT_QUERY_CONTROLS.map(
( id ) =>
`__woocommerce${ id[ 0 ].toUpperCase() }${ id.slice(
1
) }` as keyof ProductQueryArguments
);
function useDefaultWooQueryParamsForVariation(
variationName: string | undefined
): Partial< ProductQueryArguments > {
const variationAttributes: QueryBlockAttributes = useSelect(
( select ) =>
select( 'core/blocks' )
.getBlockVariations( QUERY_LOOP_ID )
.find(
( variation: ProductQueryBlock ) =>
variation.name === variationName
)?.attributes
);
return variationAttributes
? Object.assign(
{},
...NAMESPACED_CONTROLS.map( ( key ) => ( {
[ key ]: variationAttributes.query[ key ],
} ) )
)
: {};
}
/**
* Gets the id of a specific stock status from its text label
*
* In theory, we could use a `saveTransform` function on the
* `FormFieldToken` component to do the conversion. However, plugins
* can add custom stock statii which don't conform to our naming
* conventions.
*/
function getStockStatusIdByLabel( statusLabel: FormTokenField.Value ) {
const label =
typeof statusLabel === 'string' ? statusLabel : statusLabel.value;
return Object.entries( STOCK_STATUS_OPTIONS ).find(
( [ , value ] ) => value === label
)?.[ 0 ];
}
export const INSPECTOR_CONTROLS = { export const INSPECTOR_CONTROLS = {
onSale: ( props: ProductQueryBlock ) => ( onSale: ( props: ProductQueryBlock ) => {
<ToggleControl const { query } = props.attributes;
label={ __(
'Show only products on sale', return (
'woo-gutenberg-products-block' <ToolsPanelItem
) } label={ __( 'Sale status', 'woo-gutenberg-products-block' ) }
checked={ props.attributes.query.__woocommerceOnSale || false } hasValue={ () => query.__woocommerceOnSale }
onChange={ ( __woocommerceOnSale ) => { >
setCustomQueryAttribute( props, { __woocommerceOnSale } ); <ToggleControl
} } label={ __(
/> 'Show only products on sale',
), 'woo-gutenberg-products-block'
) }
checked={ query.__woocommerceOnSale || false }
onChange={ ( __woocommerceOnSale ) => {
setCustomQueryAttribute( props, {
__woocommerceOnSale,
} );
} }
/>
</ToolsPanelItem>
);
},
stockStatus: ( props: ProductQueryBlock ) => {
const { query } = props.attributes;
return (
<ToolsPanelItem
label={ __( 'Stock status', 'woo-gutenberg-products-block' ) }
hasValue={ () => query.__woocommerceStockStatus }
>
<FormTokenField
label={ __(
'Stock status',
'woo-gutenberg-products-block'
) }
onChange={ ( statusLabels ) => {
const __woocommerceStockStatus = statusLabels
.map( getStockStatusIdByLabel )
.filter( Boolean ) as string[];
setCustomQueryAttribute( props, {
__woocommerceStockStatus,
} );
} }
suggestions={ Object.values( STOCK_STATUS_OPTIONS ) }
validateInput={ ( value: string ) =>
Object.values( STOCK_STATUS_OPTIONS ).includes( value )
}
value={
query?.__woocommerceStockStatus?.map(
( key ) => STOCK_STATUS_OPTIONS[ key ]
) || []
}
__experimentalExpandOnFocus={ true }
/>
</ToolsPanelItem>
);
},
}; };
export const withProductQueryControls = export const withProductQueryControls =
< T extends EditorBlock< T > >( BlockEdit: ElementType ) => < T extends EditorBlock< T > >( BlockEdit: ElementType ) =>
( props: ProductQueryBlock ) => { ( props: ProductQueryBlock ) => {
const allowedControls = useAllowedControls( props.attributes ); const allowedControls = useAllowedControls( props.attributes );
const defaultWooQueryParams = useDefaultWooQueryParamsForVariation(
const availableControls = Object.entries( INSPECTOR_CONTROLS ).filter( props.attributes.namespace
( [ key ] ) => allowedControls?.includes( key )
); );
return isWooQueryBlockVariation( props ) &&
availableControls.length > 0 ? ( return isWooQueryBlockVariation( props ) ? (
<> <>
<BlockEdit { ...props } /> <BlockEdit { ...props } />
<InspectorControls> <InspectorControls>
<PanelBody> <ToolsPanel
{ availableControls.map( ( [ key, Control ] ) => ( class="woocommerce-product-query-toolspanel"
<Control key={ key } { ...props } /> label={ __(
) ) } 'Product filters',
</PanelBody> 'woo-gutenberg-products-block'
) }
resetAll={ () => {
setCustomQueryAttribute(
props,
defaultWooQueryParams
);
} }
>
{ Object.entries( INSPECTOR_CONTROLS ).map(
( [ key, Control ] ) =>
allowedControls?.includes( key ) ? (
<Control { ...props } />
) : null
) }
</ToolsPanel>
</InspectorControls> </InspectorControls>
</> </>
) : ( ) : (
@ -58,4 +183,4 @@ export const withProductQueryControls =
); );
}; };
addFilter( 'editor.BlockEdit', 'core/query', withProductQueryControls ); addFilter( 'editor.BlockEdit', QUERY_LOOP_ID, withProductQueryControls );

View File

@ -3,6 +3,11 @@
*/ */
import type { EditorBlock } from '@woocommerce/types'; import type { EditorBlock } from '@woocommerce/types';
// The interface below disables the forbidden underscores
// naming convention because we are namespacing our
// custom attributes inside a core block. Prefixing with underscores
// will help signify our intentions.
/* eslint-disable @typescript-eslint/naming-convention */
export interface ProductQueryArguments { export interface ProductQueryArguments {
/** /**
* Display only products on sale. * Display only products on sale.
@ -27,27 +32,27 @@ export interface ProductQueryArguments {
* ) * )
* ``` * ```
*/ */
// Disabling naming convention because we are namespacing our
// custom attributes inside a core block. Prefixing with underscores
// will help signify our intentions.
// eslint-disable-next-line @typescript-eslint/naming-convention
__woocommerceOnSale?: boolean; __woocommerceOnSale?: boolean;
/**
* Filter products by their stock status.
*
* Will generate the following `meta_query`:
*
* ```
* array(
* 'key' => '_stock_status',
* 'value' => (array) $stock_statii,
* 'compare' => 'IN',
* ),
* ```
*/
__woocommerceStockStatus?: string[];
} }
/* eslint-enable */
export type ProductQueryBlock = EditorBlock< QueryBlockAttributes >; export type ProductQueryBlock = EditorBlock< QueryBlockAttributes >;
export interface ProductQueryAttributes { export type ProductQueryBlockQuery = QueryBlockQuery & ProductQueryArguments;
/**
* An array of controls to disable in the inspector.
*
* @example `[ 'stockStatus' ]` will not render the dropdown for stock status.
*/
disabledInspectorControls?: string[];
/**
* Query attributes that define which products will be fetched.
*/
query?: ProductQueryArguments;
}
export interface QueryBlockAttributes { export interface QueryBlockAttributes {
allowedControls?: string[]; allowedControls?: string[];
@ -56,7 +61,7 @@ export interface QueryBlockAttributes {
columns?: number; columns?: number;
}; };
namespace?: string; namespace?: string;
query: QueryBlockQuery & ProductQueryArguments; query: ProductQueryBlockQuery;
} }
export interface QueryBlockQuery { export interface QueryBlockQuery {

View File

@ -7,6 +7,7 @@ import { store as WP_BLOCKS_STORE } from '@wordpress/blocks';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { QUERY_LOOP_ID } from './constants';
import { import {
ProductQueryArguments, ProductQueryArguments,
ProductQueryBlock, ProductQueryBlock,
@ -29,7 +30,7 @@ export function ArrayXOR< T extends Array< unknown > >( a: T, b: T ) {
*/ */
export function isWooQueryBlockVariation( block: ProductQueryBlock ) { export function isWooQueryBlockVariation( block: ProductQueryBlock ) {
return ( return (
block.name === 'core/query' && block.name === QUERY_LOOP_ID &&
Object.values( QueryVariation ).includes( Object.values( QueryVariation ).includes(
block.attributes.namespace as QueryVariation block.attributes.namespace as QueryVariation
) )
@ -71,7 +72,7 @@ export function useAllowedControls(
return useSelect( return useSelect(
( select ) => ( select ) =>
select( WP_BLOCKS_STORE ).getActiveBlockVariation( select( WP_BLOCKS_STORE ).getActiveBlockVariation(
'core/query', QUERY_LOOP_ID,
attributes attributes
)?.allowedControls, )?.allowedControls,

View File

@ -14,12 +14,13 @@ import {
DEFAULT_ALLOWED_CONTROLS, DEFAULT_ALLOWED_CONTROLS,
INNER_BLOCKS_TEMPLATE, INNER_BLOCKS_TEMPLATE,
QUERY_DEFAULT_ATTRIBUTES, QUERY_DEFAULT_ATTRIBUTES,
QUERY_LOOP_ID,
} from '../constants'; } from '../constants';
const VARIATION_NAME = 'woocommerce/product-query'; const VARIATION_NAME = 'woocommerce/product-query';
if ( isExperimentalBuild() ) { if ( isExperimentalBuild() ) {
registerBlockVariation( 'core/query', { registerBlockVariation( QUERY_LOOP_ID, {
name: VARIATION_NAME, name: VARIATION_NAME,
title: __( 'Product Query', 'woo-gutenberg-products-block' ), title: __( 'Product Query', 'woo-gutenberg-products-block' ),
isActive: ( blockAttributes ) => isActive: ( blockAttributes ) =>

View File

@ -10,9 +10,10 @@ import { Icon, percent } from '@wordpress/icons';
* Internal dependencies * Internal dependencies
*/ */
import { import {
DEFAULT_CORE_ALLOWED_CONTROLS, DEFAULT_ALLOWED_CONTROLS,
INNER_BLOCKS_TEMPLATE, INNER_BLOCKS_TEMPLATE,
QUERY_DEFAULT_ATTRIBUTES, QUERY_DEFAULT_ATTRIBUTES,
QUERY_LOOP_ID,
} from '../constants'; } from '../constants';
import { ArrayXOR } from '../utils'; import { ArrayXOR } from '../utils';
@ -20,7 +21,7 @@ const VARIATION_NAME = 'woocommerce/query-products-on-sale';
const DISABLED_INSPECTOR_CONTROLS = [ 'onSale' ]; const DISABLED_INSPECTOR_CONTROLS = [ 'onSale' ];
if ( isExperimentalBuild() ) { if ( isExperimentalBuild() ) {
registerBlockVariation( 'core/query', { registerBlockVariation( QUERY_LOOP_ID, {
name: VARIATION_NAME, name: VARIATION_NAME,
title: __( 'Products on Sale', 'woo-gutenberg-products-block' ), title: __( 'Products on Sale', 'woo-gutenberg-products-block' ),
isActive: ( blockAttributes ) => isActive: ( blockAttributes ) =>
@ -47,7 +48,7 @@ if ( isExperimentalBuild() ) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
allowedControls: ArrayXOR( allowedControls: ArrayXOR(
DEFAULT_CORE_ALLOWED_CONTROLS, DEFAULT_ALLOWED_CONTROLS,
DISABLED_INSPECTOR_CONTROLS DISABLED_INSPECTOR_CONTROLS
), ),
innerBlocks: INNER_BLOCKS_TEMPLATE, innerBlocks: INNER_BLOCKS_TEMPLATE,

View File

@ -141,7 +141,6 @@ class ProductQuery extends AbstractBlock {
}, },
$common_query_values $common_query_values
); );
} }
/** /**
@ -155,6 +154,23 @@ class ProductQuery extends AbstractBlock {
); );
} }
/**
* Return a query for products depending on their stock status.
*
* @param array $stock_statii An array of acceptable stock statii.
* @return array
*/
private function get_stock_status_query( $stock_statii ) {
return array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'key' => '_stock_status',
'value' => (array) $stock_statii,
'compare' => 'IN',
),
);
}
/** /**
* Set the query vars that are used by filter blocks. * Set the query vars that are used by filter blocks.
* *
@ -231,9 +247,12 @@ class ProductQuery extends AbstractBlock {
* @return array * @return array
*/ */
private function get_queries_by_attributes( $parsed_block ) { private function get_queries_by_attributes( $parsed_block ) {
$on_sale_enabled = isset( $parsed_block['attrs']['query']['__woocommerceOnSale'] ) && true === $parsed_block['attrs']['query']['__woocommerceOnSale']; $query = $parsed_block['attrs']['query'];
$on_sale_enabled = isset( $query['__woocommerceOnSale'] ) && true === $query['__woocommerceOnSale'];
return array( return array(
'on_sale' => ( $on_sale_enabled ? $this->get_on_sale_products_query() : array() ), 'on_sale' => ( $on_sale_enabled ? $this->get_on_sale_products_query() : array() ),
'stock_status' => isset( $query['__woocommerceStockStatus'] ) ? $this->get_stock_status_query( $query['__woocommerceStockStatus'] ) : array(),
); );
} }