diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-search/constants.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-search/constants.ts new file mode 100644 index 00000000000..ae42399cf3d --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-search/constants.ts @@ -0,0 +1,10 @@ +export const SEARCH_BLOCK_NAME = 'core/search'; +export const SEARCH_VARIATION_NAME = 'woocommerce/product-search'; + +export enum PositionOptions { + OUTSIDE = 'button-outside', + INSIDE = 'button-inside', + NO_BUTTON = 'no-button', + BUTTON_ONLY = 'button-only', + INPUT_AND_BUTTON = 'input-and-button', +} diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-search/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-search/index.tsx index 9691e2cdf9a..89bd2ac4dac 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-search/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-search/index.tsx @@ -2,6 +2,7 @@ /** * External dependencies */ +import { addFilter } from '@wordpress/hooks'; import { store as blockEditorStore, Warning } from '@wordpress/block-editor'; import { useDispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; @@ -9,6 +10,7 @@ import { Icon, search } from '@wordpress/icons'; import { getSettingWithCoercion } from '@woocommerce/settings'; import { isBoolean } from '@woocommerce/types'; import { Button } from '@wordpress/components'; +import type { Block as BlockType } from '@wordpress/blocks'; import { // @ts-ignore waiting for @types/wordpress__blocks update registerBlockVariation, @@ -21,8 +23,10 @@ import { */ import './style.scss'; import './editor.scss'; +import { withProductSearchControls } from './inspector-controls'; import Block from './block'; import Edit from './edit'; +import { SEARCH_BLOCK_NAME, SEARCH_VARIATION_NAME } from './constants'; const isBlockVariationAvailable = getSettingWithCoercion( 'isBlockVariationAvailable', @@ -71,6 +75,7 @@ const PRODUCT_SEARCH_ATTRIBUTES = { query: { post_type: 'product', }, + namespace: SEARCH_VARIATION_NAME, }; const DeprecatedBlockEdit = ( { clientId }: { clientId: string } ) => { @@ -115,7 +120,7 @@ const DeprecatedBlockEdit = ( { clientId }: { clientId: string } ) => { ); }; -registerBlockType( 'woocommerce/product-search', { +registerBlockType( SEARCH_VARIATION_NAME, { title: __( 'Product Search', 'woocommerce' ), apiVersion: 3, icon: { @@ -146,7 +151,7 @@ registerBlockType( 'woocommerce/product-search', { isMatch: ( { idBase, instance } ) => idBase === 'woocommerce_product_search' && !! instance?.raw, transform: ( { instance } ) => - createBlock( 'woocommerce/product-search', { + createBlock( SEARCH_VARIATION_NAME, { label: instance.raw.title || PRODUCT_SEARCH_ATTRIBUTES.label, @@ -172,9 +177,31 @@ registerBlockType( 'woocommerce/product-search', { }, } ); +function registerProductSearchNamespace( props: BlockType, blockName: string ) { + if ( blockName === 'core/search' ) { + // 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` + props.attributes = { + ...props.attributes, + namespace: { + type: 'string', + }, + }; + } + + return props; +} + +addFilter( + 'blocks.registerBlockType', + SEARCH_VARIATION_NAME, + registerProductSearchNamespace +); + if ( isBlockVariationAvailable ) { registerBlockVariation( 'core/search', { - name: 'woocommerce/product-search', + name: SEARCH_VARIATION_NAME, title: __( 'Product Search', 'woocommerce' ), icon: { src: ( @@ -199,4 +226,9 @@ if ( isBlockVariationAvailable ) { ), attributes: PRODUCT_SEARCH_ATTRIBUTES, } ); + addFilter( + 'editor.BlockEdit', + SEARCH_BLOCK_NAME, + withProductSearchControls + ); } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-search/inspector-controls.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-search/inspector-controls.tsx new file mode 100644 index 00000000000..3212dd9e76e --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-search/inspector-controls.tsx @@ -0,0 +1,156 @@ +/** + * External dependencies + */ +import { type ElementType, useEffect, useState } from '@wordpress/element'; +import { EditorBlock } from '@woocommerce/types'; +import { __ } from '@wordpress/i18n'; +import { InspectorControls } from '@wordpress/block-editor'; +import { + PanelBody, + RadioControl, + ToggleControl, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Ignoring because `__experimentalToggleGroupControl` is not yet in the type definitions. + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControl as ToggleGroupControl, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Ignoring because `__experimentalToggleGroupControlOption` is not yet in the type definitions. + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControlOption as ToggleGroupControlOption, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { + getInputAndButtonOption, + getSelectedRadioControlOption, + isInputAndButtonOption, + isWooSearchBlockVariation, +} from './utils'; +import { ButtonPositionProps, ProductSearchBlockProps } from './types'; +import { PositionOptions } from './constants'; + +const ProductSearchControls = ( props: ProductSearchBlockProps ) => { + const { attributes, setAttributes } = props; + const { buttonPosition, buttonUseIcon, showLabel } = attributes; + const [ initialPosition, setInitialPosition ] = + useState< ButtonPositionProps >( buttonPosition ); + + useEffect( () => { + if ( + isInputAndButtonOption( buttonPosition ) && + initialPosition !== buttonPosition + ) { + setInitialPosition( buttonPosition ); + } + }, [ buttonPosition ] ); + + return ( + + + & + PositionOptions.INPUT_AND_BUTTON + ) => { + if ( selected !== PositionOptions.INPUT_AND_BUTTON ) { + setAttributes( { + buttonPosition: selected, + } ); + } else { + const newButtonPosition = + getInputAndButtonOption( initialPosition ); + setAttributes( { + buttonPosition: newButtonPosition, + } ); + } + } } + /> + { buttonPosition !== PositionOptions.NO_BUTTON && ( + <> + { buttonPosition !== PositionOptions.BUTTON_ONLY && ( + { + setAttributes( { + buttonPosition: value, + } ); + } } + value={ getInputAndButtonOption( + buttonPosition + ) } + > + + + + ) } + { + setAttributes( { + buttonUseIcon: value, + } ); + } } + value={ buttonUseIcon } + > + + + + + ) } + + setAttributes( { + showLabel: showInputLabel, + } ) + } + /> + + + ); +}; + +export const withProductSearchControls = + < T extends EditorBlock< T > >( BlockEdit: ElementType ) => + ( props: ProductSearchBlockProps ) => { + return isWooSearchBlockVariation( props ) ? ( + <> + + + + ) : ( + + ); + }; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-search/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-search/types.ts new file mode 100644 index 00000000000..290523ae727 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-search/types.ts @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import type { EditorBlock } from '@woocommerce/types'; + +export type ButtonPositionProps = + | 'button-outside' + | 'button-inside' + | 'no-button' + | 'button-only'; + +export interface SearchBlockAttributes { + buttonPosition: ButtonPositionProps; + buttonText?: string; + buttonUseIcon: boolean; + isSearchFieldHidden: boolean; + label?: string; + namespace?: string; + placeholder?: string; + showLabel: boolean; +} + +export type ProductSearchBlockProps = EditorBlock< SearchBlockAttributes >; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-search/utils.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-search/utils.ts new file mode 100644 index 00000000000..5b70f82a27a --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-search/utils.ts @@ -0,0 +1,75 @@ +/** + * Internal dependencies + */ +import { + PositionOptions, + SEARCH_BLOCK_NAME, + SEARCH_VARIATION_NAME, +} from './constants'; +import { ButtonPositionProps, ProductSearchBlockProps } from './types'; + +/** + * Identifies if a block is a Search block variation from our conventions + * + * We are extending Gutenberg's core Search block with our variations, and + * also adding extra namespaced attributes. If those namespaced attributes + * are present, we can be fairly sure it is our own registered variation. + * + * @param {ProductSearchBlockProps} block - A WooCommerce block. + */ +export function isWooSearchBlockVariation( block: ProductSearchBlockProps ) { + return ( + block.name === SEARCH_BLOCK_NAME && + block.attributes?.namespace === SEARCH_VARIATION_NAME + ); +} + +/** + * Checks if the given button position is a valid option for input and button placement. + * + * The function verifies if the provided `buttonPosition` matches one of the predefined + * values for placing a button either inside or outside an input field. + * + * @param {string} buttonPosition - The position of the button to check. + */ +export function isInputAndButtonOption( buttonPosition: string ): boolean { + return ( + buttonPosition === 'button-outside' || + buttonPosition === 'button-inside' + ); +} + +/** + * Returns the option for the selected button position + * + * Based on the provided `buttonPosition`, the function returns a predefined option + * if the position is valid for input and button placement. If the position is not + * one of the predefined options, it returns the original `buttonPosition`. + * + * @param {string} buttonPosition - The position of the button to evaluate. + */ +export function getSelectedRadioControlOption( + buttonPosition: string +): string { + if ( isInputAndButtonOption( buttonPosition ) ) { + return PositionOptions.INPUT_AND_BUTTON; + } + return buttonPosition; +} + +/** + * Returns the appropriate option for input and button placement based on the given value + * + * This function checks if the provided `value` is a valid option for placing a button either + * inside or outside an input field. If the `value` is valid, it is returned as is. If the `value` + * is not valid, the function returns a default option. + * + * @param {ButtonPositionProps} value - The position of the button to evaluate. + */ +export function getInputAndButtonOption( value: ButtonPositionProps ) { + if ( isInputAndButtonOption( value ) ) { + return value; + } + // The default value is 'inside' for input and button. + return PositionOptions.OUTSIDE; +} diff --git a/plugins/woocommerce/changelog/add-47890_inspector_control_to_product_search b/plugins/woocommerce/changelog/add-47890_inspector_control_to_product_search new file mode 100644 index 00000000000..351aa293cfd --- /dev/null +++ b/plugins/woocommerce/changelog/add-47890_inspector_control_to_product_search @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add inspector controls to Product Search block #51247