From af557b02816961d0e723fecba2a0b8ca0251ea55 Mon Sep 17 00:00:00 2001 From: Lucio Giannotta Date: Thu, 27 Oct 2022 19:40:10 +0200 Subject: [PATCH] Add Stock Status setting to Product Query Block (https://github.com/woocommerce/woocommerce-blocks/pull/7397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../js/blocks/product-query/constants.ts | 27 ++- .../assets/js/blocks/product-query/index.tsx | 3 +- .../product-query/inspector-controls.tsx | 177 +++++++++++++++--- .../assets/js/blocks/product-query/types.ts | 39 ++-- .../assets/js/blocks/product-query/utils.tsx | 5 +- .../variations/product-query.tsx | 3 +- .../variations/products-on-sale.tsx | 7 +- .../src/BlockTypes/ProductQuery.php | 25 ++- 8 files changed, 232 insertions(+), 54 deletions(-) diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-query/constants.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-query/constants.ts index caf00bf63a9..04ecd6114fd 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-query/constants.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-query/constants.ts @@ -1,6 +1,7 @@ /** * External dependencies */ +import { getSetting } from '@woocommerce/settings'; import type { InnerBlockTemplate } from '@wordpress/blocks'; /** @@ -8,15 +9,36 @@ import type { InnerBlockTemplate } from '@wordpress/blocks'; */ 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 ALL_PRODUCT_QUERY_CONTROLS = [ 'onSale' ]; +export const ALL_PRODUCT_QUERY_CONTROLS = [ 'onSale', 'stockStatus' ]; export const DEFAULT_ALLOWED_CONTROLS = [ ...DEFAULT_CORE_ALLOWED_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 = { allowedControls: DEFAULT_ALLOWED_CONTROLS, displayLayout: { @@ -35,6 +57,9 @@ export const QUERY_DEFAULT_ATTRIBUTES: QueryBlockAttributes = { exclude: [], sticky: '', inherit: false, + __woocommerceStockStatus: GLOBAL_HIDE_OUT_OF_STOCK + ? Object.keys( objectOmit( STOCK_STATUS_OPTIONS, 'outofstock' ) ) + : Object.keys( STOCK_STATUS_OPTIONS ), }, }; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-query/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-query/index.tsx index dbdb98e2907..5a9da69d634 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-query/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-query/index.tsx @@ -7,6 +7,7 @@ import { addFilter } from '@wordpress/hooks'; /** * Internal dependencies */ +import { QUERY_LOOP_ID } from './constants'; import './inspector-controls'; import './variations/product-query'; import './variations/products-on-sale'; @@ -15,7 +16,7 @@ function registerProductQueryVariationAttributes( props: Block, blockName: string ) { - if ( blockName === 'core/query' ) { + if ( blockName === QUERY_LOOP_ID ) { // Gracefully handle if settings.attributes is undefined. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore -- We need this because `attributes` is marked as `readonly` diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-query/inspector-controls.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-query/inspector-controls.tsx index 58f3865b5de..72a4b173c44 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-query/inspector-controls.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-query/inspector-controls.tsx @@ -1,56 +1,181 @@ /** * External dependencies */ +import { ElementType } from 'react'; import { __ } from '@wordpress/i18n'; import { InspectorControls } from '@wordpress/block-editor'; -import { PanelBody, ToggleControl } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; import { addFilter } from '@wordpress/hooks'; 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 */ -import { ProductQueryBlock } from './types'; +import { + ProductQueryArguments, + ProductQueryBlock, + QueryBlockAttributes, +} from './types'; import { isWooQueryBlockVariation, setCustomQueryAttribute, useAllowedControls, } 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 = { - onSale: ( props: ProductQueryBlock ) => ( - { - setCustomQueryAttribute( props, { __woocommerceOnSale } ); - } } - /> - ), + onSale: ( props: ProductQueryBlock ) => { + const { query } = props.attributes; + + return ( + query.__woocommerceOnSale } + > + { + setCustomQueryAttribute( props, { + __woocommerceOnSale, + } ); + } } + /> + + ); + }, + stockStatus: ( props: ProductQueryBlock ) => { + const { query } = props.attributes; + + return ( + query.__woocommerceStockStatus } + > + { + 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 } + /> + + ); + }, }; export const withProductQueryControls = < T extends EditorBlock< T > >( BlockEdit: ElementType ) => ( props: ProductQueryBlock ) => { const allowedControls = useAllowedControls( props.attributes ); - - const availableControls = Object.entries( INSPECTOR_CONTROLS ).filter( - ( [ key ] ) => allowedControls?.includes( key ) + const defaultWooQueryParams = useDefaultWooQueryParamsForVariation( + props.attributes.namespace ); - return isWooQueryBlockVariation( props ) && - availableControls.length > 0 ? ( + + return isWooQueryBlockVariation( props ) ? ( <> - - { availableControls.map( ( [ key, Control ] ) => ( - - ) ) } - + { + setCustomQueryAttribute( + props, + defaultWooQueryParams + ); + } } + > + { Object.entries( INSPECTOR_CONTROLS ).map( + ( [ key, Control ] ) => + allowedControls?.includes( key ) ? ( + + ) : null + ) } + ) : ( @@ -58,4 +183,4 @@ export const withProductQueryControls = ); }; -addFilter( 'editor.BlockEdit', 'core/query', withProductQueryControls ); +addFilter( 'editor.BlockEdit', QUERY_LOOP_ID, withProductQueryControls ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-query/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-query/types.ts index 2a7d9a59aea..91ec073160b 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-query/types.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-query/types.ts @@ -3,6 +3,11 @@ */ 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 { /** * 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; + /** + * 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 interface ProductQueryAttributes { - /** - * 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 type ProductQueryBlockQuery = QueryBlockQuery & ProductQueryArguments; export interface QueryBlockAttributes { allowedControls?: string[]; @@ -56,7 +61,7 @@ export interface QueryBlockAttributes { columns?: number; }; namespace?: string; - query: QueryBlockQuery & ProductQueryArguments; + query: ProductQueryBlockQuery; } export interface QueryBlockQuery { diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-query/utils.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-query/utils.tsx index e0349964fee..f9c0a45bac0 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-query/utils.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-query/utils.tsx @@ -7,6 +7,7 @@ import { store as WP_BLOCKS_STORE } from '@wordpress/blocks'; /** * Internal dependencies */ +import { QUERY_LOOP_ID } from './constants'; import { ProductQueryArguments, ProductQueryBlock, @@ -29,7 +30,7 @@ export function ArrayXOR< T extends Array< unknown > >( a: T, b: T ) { */ export function isWooQueryBlockVariation( block: ProductQueryBlock ) { return ( - block.name === 'core/query' && + block.name === QUERY_LOOP_ID && Object.values( QueryVariation ).includes( block.attributes.namespace as QueryVariation ) @@ -71,7 +72,7 @@ export function useAllowedControls( return useSelect( ( select ) => select( WP_BLOCKS_STORE ).getActiveBlockVariation( - 'core/query', + QUERY_LOOP_ID, attributes )?.allowedControls, diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-query/variations/product-query.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-query/variations/product-query.tsx index df98239d664..a5b0f5a1a5e 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-query/variations/product-query.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-query/variations/product-query.tsx @@ -14,12 +14,13 @@ import { DEFAULT_ALLOWED_CONTROLS, INNER_BLOCKS_TEMPLATE, QUERY_DEFAULT_ATTRIBUTES, + QUERY_LOOP_ID, } from '../constants'; const VARIATION_NAME = 'woocommerce/product-query'; if ( isExperimentalBuild() ) { - registerBlockVariation( 'core/query', { + registerBlockVariation( QUERY_LOOP_ID, { name: VARIATION_NAME, title: __( 'Product Query', 'woo-gutenberg-products-block' ), isActive: ( blockAttributes ) => diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-query/variations/products-on-sale.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-query/variations/products-on-sale.tsx index a5cc390b829..5c7ea294197 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-query/variations/products-on-sale.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-query/variations/products-on-sale.tsx @@ -10,9 +10,10 @@ import { Icon, percent } from '@wordpress/icons'; * Internal dependencies */ import { - DEFAULT_CORE_ALLOWED_CONTROLS, + DEFAULT_ALLOWED_CONTROLS, INNER_BLOCKS_TEMPLATE, QUERY_DEFAULT_ATTRIBUTES, + QUERY_LOOP_ID, } from '../constants'; import { ArrayXOR } from '../utils'; @@ -20,7 +21,7 @@ const VARIATION_NAME = 'woocommerce/query-products-on-sale'; const DISABLED_INSPECTOR_CONTROLS = [ 'onSale' ]; if ( isExperimentalBuild() ) { - registerBlockVariation( 'core/query', { + registerBlockVariation( QUERY_LOOP_ID, { name: VARIATION_NAME, title: __( 'Products on Sale', 'woo-gutenberg-products-block' ), isActive: ( blockAttributes ) => @@ -47,7 +48,7 @@ if ( isExperimentalBuild() ) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore allowedControls: ArrayXOR( - DEFAULT_CORE_ALLOWED_CONTROLS, + DEFAULT_ALLOWED_CONTROLS, DISABLED_INSPECTOR_CONTROLS ), innerBlocks: INNER_BLOCKS_TEMPLATE, diff --git a/plugins/woocommerce-blocks/src/BlockTypes/ProductQuery.php b/plugins/woocommerce-blocks/src/BlockTypes/ProductQuery.php index 0ccaea714d1..8814e6c7a1f 100644 --- a/plugins/woocommerce-blocks/src/BlockTypes/ProductQuery.php +++ b/plugins/woocommerce-blocks/src/BlockTypes/ProductQuery.php @@ -141,7 +141,6 @@ class ProductQuery extends AbstractBlock { }, $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. * @@ -231,9 +247,12 @@ class ProductQuery extends AbstractBlock { * @return array */ 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( - '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(), ); }