Refactor Checkout Inner Block Registration and Render frontend forced blocks (https://github.com/woocommerce/woocommerce-blocks/pull/4671)
* Append forced blocks * Fix child detection * Improve render logic to remove clone element * Areas instead of block names * Revert "Areas instead of block names" This reverts commit c8d68e6424313ed15ca1eb1f91a3edfc24d06a8d. * revert area change * Registration system * Refactor block registration to handle components + forcing * Remove need for atomic block registration * add attributes to namespaced blocks only * Update area names to new format * Avoid passing custom props to DOM * Put back usage of cloneelement for DOM elements * correct case of innerBlockAreas * Inline documentation for renderParentBlock * Play nice with other attributes when registering forced blocks
This commit is contained in:
parent
7e7cf87dc0
commit
b385d4005c
|
@ -5,98 +5,218 @@ import { renderFrontend } from '@woocommerce/base-utils';
|
|||
import {
|
||||
Fragment,
|
||||
Suspense,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
cloneElement,
|
||||
} from '@wordpress/element';
|
||||
import parse from 'html-react-parser';
|
||||
import {
|
||||
getRegisteredBlocks,
|
||||
isInnerBlockArea,
|
||||
} from '@woocommerce/blocks-checkout';
|
||||
|
||||
interface renderBlockProps {
|
||||
/**
|
||||
* This file contains logic used on the frontend to convert DOM elements (saved by the block editor) to React
|
||||
* Components. These components are registered using registerBlockComponent() and registerCheckoutBlock() and map 1:1
|
||||
* to a block by name.
|
||||
*
|
||||
* Blocks using this system will have their blockName stored as a data attribute, for example:
|
||||
* <div data-block-name="woocommerce/product-title"></div>
|
||||
*
|
||||
* This block name is then read, and using the map, dynamically converted to a real React Component.
|
||||
*
|
||||
* @see registerBlockComponent
|
||||
* @see registerCheckoutBlock
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets a component from the block map for a given block name, or returns null if a component is not registered.
|
||||
*/
|
||||
const getBlockComponentFromMap = (
|
||||
blockName: string,
|
||||
blockMap: Record< string, React.ReactNode >
|
||||
): React.ElementType | null => {
|
||||
return blockName && blockMap[ blockName ]
|
||||
? ( blockMap[ blockName ] as React.ElementType )
|
||||
: null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render forced blocks which are missing from the template.
|
||||
*
|
||||
* Forced blocks are registered in registerCheckoutBlock. If a block is forced, it will be inserted in the editor
|
||||
* automatically, however, until that happens they may be missing from the frontend. To fix this, we look up what blocks
|
||||
* are registered as forced, and then append them here if they are missing.
|
||||
*
|
||||
* @see registerCheckoutBlock
|
||||
*/
|
||||
const renderForcedBlocks = (
|
||||
blockName: string,
|
||||
blockMap: Record< string, React.ReactNode >,
|
||||
// Current children from the parent (siblings of the forced block)
|
||||
blockChildren: HTMLCollection | null
|
||||
) => {
|
||||
if ( ! isInnerBlockArea( blockName ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentBlocks = blockChildren
|
||||
? ( Array.from( blockChildren )
|
||||
.map( ( element: Element ) =>
|
||||
element instanceof HTMLElement
|
||||
? element?.dataset.blockName || null
|
||||
: null
|
||||
)
|
||||
.filter( Boolean ) as string[] )
|
||||
: [];
|
||||
|
||||
const forcedBlocks = getRegisteredBlocks( blockName ).filter(
|
||||
( { block, force } ) =>
|
||||
force === true && ! currentBlocks.includes( block )
|
||||
);
|
||||
|
||||
return forcedBlocks.map(
|
||||
( { block, component }, index: number ): JSX.Element | null => {
|
||||
const ForcedComponent = component
|
||||
? component
|
||||
: getBlockComponentFromMap( block, blockMap );
|
||||
return ForcedComponent ? (
|
||||
<ForcedComponent key={ `${ blockName }_forced_${ index }` } />
|
||||
) : null;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively replace block markup in the DOM with React Components.
|
||||
*/
|
||||
const renderInnerBlocks = ( {
|
||||
// This is the parent block we're working within (see renderParentBlock)
|
||||
blockName: parentBlockName,
|
||||
// This is the map of blockNames->components
|
||||
blockMap,
|
||||
// Component which inner blocks are wrapped with.
|
||||
blockWrapper,
|
||||
// The children from the DOM we're currently iterating over.
|
||||
children,
|
||||
// Current depth of the children. Used to ensure keys are unique.
|
||||
depth = 1,
|
||||
}: {
|
||||
// Parent Block Name. Used for inner block component mapping.
|
||||
blockName: string;
|
||||
// Map of block names to block components for children.
|
||||
blockMap: Record< string, React.ReactNode >;
|
||||
// Wrapper for inner components.
|
||||
blockWrapper?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface renderParentBlockProps extends renderBlockProps {
|
||||
// React component to use as a replacement.
|
||||
Block: React.FunctionComponent;
|
||||
// CSS selector to match the elements to replace.
|
||||
selector: string;
|
||||
// Function to generate the props object for the block.
|
||||
getProps: ( el: Element, i: number ) => Record< string, unknown >;
|
||||
}
|
||||
|
||||
interface renderInnerBlockProps extends renderBlockProps {
|
||||
blockWrapper?: React.ElementType;
|
||||
// Elements from the DOM being converted to components.
|
||||
children: HTMLCollection;
|
||||
// Depth within the DOM hierarchy.
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces saved block HTML markup with Inner Block Components.
|
||||
*/
|
||||
const renderInnerBlocks = ( {
|
||||
blockName: parentBlockName,
|
||||
blockMap,
|
||||
blockWrapper,
|
||||
depth = 1,
|
||||
children,
|
||||
}: renderInnerBlockProps ): ( JSX.Element | null )[] | null => {
|
||||
return Array.from( children ).map( ( el: Element, index: number ) => {
|
||||
} ): ( JSX.Element | null )[] | null => {
|
||||
if ( ! children || children.length === 0 ) {
|
||||
return null;
|
||||
}
|
||||
return Array.from( children ).map( ( element: Element, index: number ) => {
|
||||
/**
|
||||
* This will grab the blockName from the data- attributes stored in block markup. Without a blockName, we cannot
|
||||
* convert the HTMLElement to a React component.
|
||||
*/
|
||||
const { blockName = '', ...componentProps } = {
|
||||
key: `${ parentBlockName }_${ depth }_${ index }`,
|
||||
...( el instanceof HTMLElement ? el.dataset : {} ),
|
||||
...( element instanceof HTMLElement ? element.dataset : {} ),
|
||||
};
|
||||
|
||||
const componentChildren =
|
||||
el.children && el.children.length
|
||||
? renderInnerBlocks( {
|
||||
children: el.children,
|
||||
blockName: parentBlockName,
|
||||
blockMap,
|
||||
depth: depth + 1,
|
||||
blockWrapper,
|
||||
} )
|
||||
: null;
|
||||
const InnerBlockComponent = getBlockComponentFromMap(
|
||||
blockName,
|
||||
blockMap
|
||||
);
|
||||
|
||||
const LayoutComponent =
|
||||
blockName && blockMap[ blockName ]
|
||||
? ( blockMap[ blockName ] as React.ElementType )
|
||||
: null;
|
||||
/**
|
||||
* If the component cannot be found, or blockName is missing, return the original element. This also ensures
|
||||
* that children within the element are processed also, since it may be an element containing block markup.
|
||||
*/
|
||||
if ( ! InnerBlockComponent ) {
|
||||
const parsedElement = parse( element.outerHTML );
|
||||
|
||||
if ( ! LayoutComponent ) {
|
||||
const element = parse( el.outerHTML );
|
||||
|
||||
if ( isValidElement( element ) ) {
|
||||
return componentChildren
|
||||
? cloneElement( element, componentProps, componentChildren )
|
||||
: cloneElement( element, componentProps );
|
||||
if ( isValidElement( parsedElement ) ) {
|
||||
const elementChildren =
|
||||
element.children && element.children.length
|
||||
? renderInnerBlocks( {
|
||||
children: element.children,
|
||||
blockName: parentBlockName,
|
||||
blockMap,
|
||||
depth: depth + 1,
|
||||
blockWrapper,
|
||||
} )
|
||||
: null;
|
||||
return elementChildren
|
||||
? cloneElement(
|
||||
parsedElement,
|
||||
componentProps,
|
||||
elementChildren
|
||||
)
|
||||
: cloneElement( parsedElement, componentProps );
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const LayoutComponentWrapper = ( blockWrapper
|
||||
// This will wrap inner blocks with the provided wrapper. If no wrapper is provided, we default to Fragment.
|
||||
const InnerBlockComponentWrapper = blockWrapper
|
||||
? blockWrapper
|
||||
: Fragment ) as React.ElementType;
|
||||
: Fragment;
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
key={ `${ parentBlockName }_${ depth }_${ index }_suspense` }
|
||||
fallback={ <div className="wc-block-placeholder" /> }
|
||||
>
|
||||
<LayoutComponentWrapper>
|
||||
<LayoutComponent { ...componentProps }>
|
||||
{ componentChildren }
|
||||
</LayoutComponent>
|
||||
</LayoutComponentWrapper>
|
||||
<InnerBlockComponentWrapper>
|
||||
<InnerBlockComponent { ...componentProps }>
|
||||
{
|
||||
/**
|
||||
* Within this Inner Block Component we also need to recursively render it's children. This
|
||||
* is done here with a depth+1. The same block map and parent is used, but we pass new
|
||||
* children from this element.
|
||||
*/
|
||||
renderInnerBlocks( {
|
||||
children: element.children,
|
||||
blockName: parentBlockName,
|
||||
blockMap,
|
||||
depth: depth + 1,
|
||||
blockWrapper,
|
||||
} )
|
||||
}
|
||||
{
|
||||
/**
|
||||
* In addition to the inner blocks, we may also need to render FORCED blocks which have not
|
||||
* yet been added to the inner block template. We do this by comparing the current children
|
||||
* to the list of registered forced blocks.
|
||||
*
|
||||
* @see registerCheckoutBlock
|
||||
*/
|
||||
renderForcedBlocks(
|
||||
blockName,
|
||||
blockMap,
|
||||
element.children
|
||||
)
|
||||
}
|
||||
</InnerBlockComponent>
|
||||
</InnerBlockComponentWrapper>
|
||||
</Suspense>
|
||||
);
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a block component in the place of a specified set of selectors.
|
||||
* Render a parent block on the frontend.
|
||||
*
|
||||
* This is the main entry point used on the frontend to convert Block Markup (with inner blocks) in the DOM to React
|
||||
* Components.
|
||||
*
|
||||
* This uses renderFrontend(). The difference is, renderFrontend renders a single block, but renderParentBlock() also
|
||||
* handles inner blocks by recursively running over children from the DOM.
|
||||
*
|
||||
* @see renderInnerBlocks
|
||||
* @see renderFrontend
|
||||
*/
|
||||
export const renderParentBlock = ( {
|
||||
Block,
|
||||
|
@ -105,19 +225,38 @@ export const renderParentBlock = ( {
|
|||
getProps = () => ( {} ),
|
||||
blockMap,
|
||||
blockWrapper,
|
||||
}: renderParentBlockProps ): void => {
|
||||
const getPropsWithChildren = ( el: Element, i: number ) => {
|
||||
}: {
|
||||
// Parent Block Name. Used for inner block component mapping.
|
||||
blockName: string;
|
||||
// Map of block names to block components for children.
|
||||
blockMap: Record< string, React.ReactNode >;
|
||||
// Wrapper for inner components.
|
||||
blockWrapper?: React.ElementType;
|
||||
// React component to use as a replacement.
|
||||
Block: React.FunctionComponent;
|
||||
// CSS selector to match the elements to replace.
|
||||
selector: string;
|
||||
// Function to generate the props object for the block.
|
||||
getProps: ( el: Element, i: number ) => Record< string, unknown >;
|
||||
} ): void => {
|
||||
/**
|
||||
* In addition to getProps, we need to render and return the children. This adds children to props.
|
||||
*/
|
||||
const getPropsWithChildren = ( element: Element, i: number ) => {
|
||||
const children =
|
||||
el.children && el.children.length
|
||||
element.children && element.children.length
|
||||
? renderInnerBlocks( {
|
||||
blockName,
|
||||
blockMap,
|
||||
children: el.children,
|
||||
children: element.children,
|
||||
blockWrapper,
|
||||
} )
|
||||
: null;
|
||||
return { ...getProps( el, i ), children };
|
||||
return { ...getProps( element, i ), children };
|
||||
};
|
||||
/**
|
||||
* The only difference between using renderParentBlock and renderFrontend is that here we provide children.
|
||||
*/
|
||||
renderFrontend( {
|
||||
Block,
|
||||
selector,
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
|
||||
import {
|
||||
RegisteredBlocks,
|
||||
getRegisteredBlocks,
|
||||
getRegisteredBlockTemplate,
|
||||
} from '@woocommerce/blocks-checkout';
|
||||
|
||||
/**
|
||||
|
@ -18,7 +18,7 @@ export const AdditionalFields = ( {
|
|||
}: {
|
||||
area: keyof RegisteredBlocks;
|
||||
} ): JSX.Element => {
|
||||
const registeredBlocks = getRegisteredBlocks( area );
|
||||
const registeredBlocks = getRegisteredBlockTemplate( area );
|
||||
const { 'data-block': clientId } = useBlockProps();
|
||||
const template = useForcedLayout( {
|
||||
clientId,
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
import { useBlockProps } from '@wordpress/block-editor';
|
||||
import { useCheckoutAddress } from '@woocommerce/base-context/hooks';
|
||||
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -59,7 +60,7 @@ export const Edit = ( {
|
|||
showPhoneField={ showPhoneField }
|
||||
requirePhoneField={ requirePhoneField }
|
||||
/>
|
||||
<AdditionalFields area="billingAddress" />
|
||||
<AdditionalFields area={ innerBlockAreas.BILLING_ADDRESS } />
|
||||
</FormStepBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
import { useBlockProps } from '@wordpress/block-editor';
|
||||
import { Disabled } from '@wordpress/components';
|
||||
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -40,7 +41,7 @@ export const Edit = ( {
|
|||
<Disabled>
|
||||
<Block allowCreateAccount={ allowCreateAccount } />
|
||||
</Disabled>
|
||||
<AdditionalFields area="contactInformation" />
|
||||
<AdditionalFields area={ innerBlockAreas.CONTACT_INFORMATION } />
|
||||
</FormStepBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,7 +3,10 @@
|
|||
*/
|
||||
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
|
||||
import { Main } from '@woocommerce/base-components/sidebar-layout';
|
||||
import { getRegisteredBlocks } from '@woocommerce/blocks-checkout';
|
||||
import {
|
||||
getRegisteredBlockTemplate,
|
||||
innerBlockAreas,
|
||||
} from '@woocommerce/blocks-checkout';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
|
@ -31,7 +34,9 @@ export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
|
|||
const {
|
||||
addressFieldControls: Controls,
|
||||
} = useCheckoutBlockControlsContext();
|
||||
const registeredBlocks = getRegisteredBlocks( 'fields' );
|
||||
const registeredBlocks = getRegisteredBlockTemplate(
|
||||
innerBlockAreas.CHECKOUT_FIELDS
|
||||
);
|
||||
const template = useForcedLayout( {
|
||||
clientId,
|
||||
template: [ ...ALLOWED_BLOCKS, ...registeredBlocks ],
|
||||
|
|
|
@ -6,6 +6,7 @@ import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
|
|||
import { PanelBody, Disabled, ExternalLink } from '@wordpress/components';
|
||||
import { ADMIN_URL, getSetting } from '@woocommerce/settings';
|
||||
import ExternalLinkCard from '@woocommerce/editor-components/external-link-card';
|
||||
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -72,7 +73,7 @@ export const Edit = ( props: FormStepBlockProps ): JSX.Element => {
|
|||
<Disabled>
|
||||
<Block />
|
||||
</Disabled>
|
||||
<AdditionalFields area="paymentMethods" />
|
||||
<AdditionalFields area={ innerBlockAreas.PAYMENT_METHODS } />
|
||||
</FormStepBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,7 +3,10 @@
|
|||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Icon, asterisk } from '@woocommerce/icons';
|
||||
import { registerCheckoutBlock } from '@woocommerce/blocks-checkout';
|
||||
import {
|
||||
registerCheckoutBlock,
|
||||
innerBlockAreas,
|
||||
} from '@woocommerce/blocks-checkout';
|
||||
import { lazy } from '@wordpress/element';
|
||||
import { WC_BLOCKS_BUILD_URL } from '@woocommerce/block-settings';
|
||||
|
||||
|
@ -21,11 +24,13 @@ registerCheckoutBlock( 'woocommerce/checkout-sample-block', {
|
|||
import( /* webpackChunkName: "checkout-blocks/sample" */ './frontend' )
|
||||
),
|
||||
areas: [
|
||||
'shippingAddress',
|
||||
'billingAddress',
|
||||
'contactInformation',
|
||||
'fields',
|
||||
innerBlockAreas.SHIPPING_ADDRESS,
|
||||
innerBlockAreas.BILLING_ADDRESS,
|
||||
innerBlockAreas.CONTACT_INFORMATION,
|
||||
innerBlockAreas.CHECKOUT_FIELDS,
|
||||
innerBlockAreas.CHECKOUT_TOTALS,
|
||||
],
|
||||
force: true,
|
||||
configuration: {
|
||||
title: __( 'Sample Block', 'woo-gutenberg-products-block' ),
|
||||
category: 'woocommerce',
|
||||
|
@ -43,14 +48,7 @@ registerCheckoutBlock( 'woocommerce/checkout-sample-block', {
|
|||
multiple: true,
|
||||
reusable: false,
|
||||
},
|
||||
attributes: {
|
||||
lock: {
|
||||
type: 'object',
|
||||
default: {
|
||||
remove: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
attributes: {},
|
||||
edit: Edit,
|
||||
save: Save,
|
||||
},
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { useBlockProps } from '@wordpress/block-editor';
|
||||
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -52,7 +53,7 @@ export const Edit = ( {
|
|||
showPhoneField={ showPhoneField }
|
||||
requirePhoneField={ requirePhoneField }
|
||||
/>
|
||||
<AdditionalFields area="shippingAddress" />
|
||||
<AdditionalFields area={ innerBlockAreas.SHIPPING_ADDRESS } />
|
||||
</FormStepBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,6 +6,7 @@ import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
|
|||
import { PanelBody, Disabled, ExternalLink } from '@wordpress/components';
|
||||
import { ADMIN_URL, getSetting } from '@woocommerce/settings';
|
||||
import ExternalLinkCard from '@woocommerce/editor-components/external-link-card';
|
||||
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -115,7 +116,7 @@ export const Edit = ( {
|
|||
<Disabled>
|
||||
<Block />
|
||||
</Disabled>
|
||||
<AdditionalFields area="shippingMethods" />
|
||||
<AdditionalFields area={ innerBlockAreas.SHIPPING_METHODS } />
|
||||
</FormStepBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,7 +3,10 @@
|
|||
*/
|
||||
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
|
||||
import { Sidebar } from '@woocommerce/base-components/sidebar-layout';
|
||||
import { getRegisteredBlocks } from '@woocommerce/blocks-checkout';
|
||||
import {
|
||||
getRegisteredBlockTemplate,
|
||||
innerBlockAreas,
|
||||
} from '@woocommerce/blocks-checkout';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
|
@ -17,7 +20,9 @@ const TEMPLATE: InnerBlockTemplate[] = [
|
|||
|
||||
export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
|
||||
const blockProps = useBlockProps();
|
||||
const registeredBlocks = getRegisteredBlocks( 'totals' );
|
||||
const registeredBlocks = getRegisteredBlockTemplate(
|
||||
innerBlockAreas.CHECKOUT_TOTALS
|
||||
);
|
||||
const template = useForcedLayout( {
|
||||
clientId,
|
||||
template: [ ...ALLOWED_BLOCKS, ...registeredBlocks ],
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { innerBlockAreas, RegisteredBlock } from './types';
|
||||
import { registeredBlocks } from './registered-blocks';
|
||||
|
||||
/**
|
||||
* Get a list of blocks available within a specific area.
|
||||
*/
|
||||
export const getRegisteredBlocks = (
|
||||
area: innerBlockAreas
|
||||
): Array< RegisteredBlock > => {
|
||||
return [ ...( registeredBlocks[ area ] || [] ) ];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a list of blocks names in inner block template format.
|
||||
*/
|
||||
export const getRegisteredBlockTemplate = (
|
||||
area: innerBlockAreas
|
||||
): Array< string > =>
|
||||
getRegisteredBlocks( area ).map(
|
||||
( block: RegisteredBlock ) => block.block
|
||||
);
|
||||
|
||||
/**
|
||||
* Check area is valid.
|
||||
*/
|
||||
export const isInnerBlockArea = ( area: string ): area is innerBlockAreas => {
|
||||
return Object.values( innerBlockAreas ).includes( area as innerBlockAreas );
|
||||
};
|
|
@ -1,204 +1,3 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { BlockConfiguration } from '@wordpress/blocks';
|
||||
import { registerBlockComponent } from '@woocommerce/blocks-registry';
|
||||
import { registerExperimentalBlockType } from '@woocommerce/block-settings';
|
||||
import type { LazyExoticComponent } from 'react';
|
||||
import { isObject } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* List of block areas where blocks can be registered for use. Keyed by area name.
|
||||
*/
|
||||
export type RegisteredBlocks = {
|
||||
fields: Array< string >;
|
||||
totals: Array< string >;
|
||||
contactInformation: Array< string >;
|
||||
shippingAddress: Array< string >;
|
||||
billingAddress: Array< string >;
|
||||
shippingMethods: Array< string >;
|
||||
paymentMethods: Array< string >;
|
||||
};
|
||||
|
||||
let registeredBlocks: RegisteredBlocks = {
|
||||
fields: [],
|
||||
totals: [],
|
||||
contactInformation: [ 'core/paragraph' ],
|
||||
shippingAddress: [ 'core/paragraph' ],
|
||||
billingAddress: [ 'core/paragraph' ],
|
||||
shippingMethods: [ 'core/paragraph' ],
|
||||
paymentMethods: [ 'core/paragraph' ],
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that an option is of the given type. Otherwise, throws an error.
|
||||
*
|
||||
* @throws Will throw an error if the type of the option doesn't match the expected type.
|
||||
*/
|
||||
const assertType = (
|
||||
optionName: string,
|
||||
option: unknown,
|
||||
expectedType: unknown
|
||||
): void => {
|
||||
const actualType = typeof option;
|
||||
if ( actualType !== expectedType ) {
|
||||
throw new Error(
|
||||
`Incorrect value for the ${ optionName } argument when registering a checkout block. It was a ${ actualType }, but must be a ${ expectedType }.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation to ensure an area exists.
|
||||
*/
|
||||
const assertValidArea = ( area: string ): void => {
|
||||
if ( ! registeredBlocks.hasOwnProperty( area ) ) {
|
||||
throw new Error(
|
||||
`Incorrect value for the "area" argument. It was a ${ area }, but must be one of ${ Object.keys(
|
||||
registeredBlocks
|
||||
).join( ', ' ) }.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the block name.
|
||||
*
|
||||
* @throws Will throw an error if the blockname is invalid.
|
||||
*/
|
||||
const assertBlockName = ( blockName: string ): void => {
|
||||
assertType( 'blockName', blockName, 'string' );
|
||||
|
||||
if ( ! blockName ) {
|
||||
throw new Error(
|
||||
`Value for the blockName argument must not be empty.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that an option is of the given type. Otherwise, throws an error.
|
||||
*
|
||||
* @throws Will throw an error if the type of the option doesn't match the expected type.
|
||||
*/
|
||||
const assertOption = (
|
||||
options: Record< string, unknown >,
|
||||
optionName: string,
|
||||
expectedType: 'array' | 'object' | 'string' | 'boolean' | 'number'
|
||||
): void => {
|
||||
const actualType = typeof options[ optionName ];
|
||||
|
||||
if ( expectedType === 'array' ) {
|
||||
if ( ! Array.isArray( options[ optionName ] ) ) {
|
||||
throw new Error(
|
||||
`Incorrect value for the ${ optionName } argument when registering a checkout block component. It was a ${ actualType }, but must be an array.`
|
||||
);
|
||||
}
|
||||
} else if ( actualType !== expectedType ) {
|
||||
throw new Error(
|
||||
`Incorrect value for the ${ optionName } argument when registering a checkout block component. It was a ${ actualType }, but must be a ${ expectedType }.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that an option is a valid react element or lazy callback. Otherwise, throws an error.
|
||||
*
|
||||
* @throws Will throw an error if the type of the option doesn't match the expected type.
|
||||
*/
|
||||
const assertBlockComponent = (
|
||||
options: Record< string, unknown >,
|
||||
optionName: string
|
||||
) => {
|
||||
const optionValue = options[ optionName ];
|
||||
|
||||
if ( optionValue ) {
|
||||
if ( typeof optionValue === 'function' ) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isObject( optionValue ) &&
|
||||
optionValue.$$typeof &&
|
||||
optionValue.$$typeof === Symbol.for( 'react.lazy' )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Incorrect value for the ${ optionName } argument when registering a block component. Component must be a valid React Element or Lazy callback.`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a block (block name) to an area, if the area exists. If the area does not exist, an error is thrown.
|
||||
*/
|
||||
const registerBlockForArea = (
|
||||
area: keyof RegisteredBlocks,
|
||||
blockName: string
|
||||
): void | Error => {
|
||||
assertValidArea( area );
|
||||
registeredBlocks = {
|
||||
...registeredBlocks,
|
||||
[ area ]: [ ...registeredBlocks[ area ], blockName ],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a list of blocks available within a specific area.
|
||||
*/
|
||||
export const getRegisteredBlocks = (
|
||||
area: keyof RegisteredBlocks
|
||||
): Array< string > => {
|
||||
assertValidArea( area );
|
||||
return [ ...registeredBlocks[ area ] ];
|
||||
};
|
||||
|
||||
export type CheckoutBlockOptions = {
|
||||
// This is a component to render on the frontend in place of this block, when used.
|
||||
component:
|
||||
| LazyExoticComponent< React.ComponentType< unknown > >
|
||||
| ( () => JSX.Element );
|
||||
// Area(s) to add the block to. This can be a single area (string) or an array of areas.
|
||||
areas: Array< keyof RegisteredBlocks >;
|
||||
// Standard block configuration object. If not passed, the block will not be registered with WordPress and must be done manually.
|
||||
configuration?: BlockConfiguration;
|
||||
};
|
||||
|
||||
/**
|
||||
* Main API for registering a new checkout block within areas.
|
||||
*/
|
||||
export const registerCheckoutBlock = (
|
||||
blockName: string,
|
||||
options: CheckoutBlockOptions
|
||||
): void => {
|
||||
assertBlockName( blockName );
|
||||
assertOption( options, 'areas', 'array' );
|
||||
assertBlockComponent( options, 'component' );
|
||||
|
||||
/**
|
||||
* If provided with a configuration object, this registers the block with WordPress.
|
||||
*/
|
||||
if ( options?.configuration ) {
|
||||
assertOption( options, 'configuration', 'object' );
|
||||
registerExperimentalBlockType( blockName, {
|
||||
...options.configuration,
|
||||
category: 'woocommerce',
|
||||
parent: [],
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* This enables the inner block within specific areas. See RegisteredBlocks.
|
||||
*/
|
||||
options.areas.forEach( ( area ) =>
|
||||
registerBlockForArea( area, blockName )
|
||||
);
|
||||
|
||||
/**
|
||||
* This ensures the frontend component for the checkout block is available.
|
||||
*/
|
||||
registerBlockComponent( {
|
||||
blockName,
|
||||
component: options.component,
|
||||
} );
|
||||
};
|
||||
export * from './get-registered-blocks';
|
||||
export * from './register-checkout-block';
|
||||
export * from './types';
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { registerBlockComponent } from '@woocommerce/blocks-registry';
|
||||
import { registerExperimentalBlockType } from '@woocommerce/block-settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { CheckoutBlockOptions } from './types';
|
||||
import {
|
||||
assertBlockName,
|
||||
assertOption,
|
||||
assertBlockComponent,
|
||||
assertValidArea,
|
||||
} from './utils';
|
||||
import { registeredBlocks } from './registered-blocks';
|
||||
|
||||
/**
|
||||
* Main API for registering a new checkout block within areas.
|
||||
*/
|
||||
export const registerCheckoutBlock = (
|
||||
blockName: string,
|
||||
options: CheckoutBlockOptions
|
||||
): void => {
|
||||
assertBlockName( blockName );
|
||||
assertOption( options, 'areas', 'array' );
|
||||
assertBlockComponent( options, 'component' );
|
||||
|
||||
/**
|
||||
* This ensures the frontend component for the checkout block is available.
|
||||
*/
|
||||
registerBlockComponent( {
|
||||
blockName,
|
||||
component: options.component,
|
||||
} );
|
||||
|
||||
/**
|
||||
* If provided with a configuration object, this registers the block with WordPress.
|
||||
*/
|
||||
if ( options?.configuration ) {
|
||||
assertOption( options, 'configuration', 'object' );
|
||||
|
||||
const blockConfiguration = {
|
||||
category: 'woocommerce',
|
||||
parent: [],
|
||||
...options.configuration,
|
||||
};
|
||||
|
||||
if ( options.force ) {
|
||||
blockConfiguration.attributes = {
|
||||
...( options.configuration?.attributes || {} ),
|
||||
lock: {
|
||||
...( options.configuration?.attributes?.lock || {
|
||||
type: 'object',
|
||||
default: {
|
||||
remove: true,
|
||||
},
|
||||
} ),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerExperimentalBlockType( blockName, blockConfiguration );
|
||||
}
|
||||
|
||||
/**
|
||||
* This enables the inner block within specific areas of the checkout. An area maps to the parent block name.
|
||||
*/
|
||||
options.areas.forEach( ( area ) => {
|
||||
assertValidArea( area );
|
||||
registeredBlocks[ area ] = [
|
||||
...registeredBlocks[ area ],
|
||||
{
|
||||
block: blockName,
|
||||
component: options.component,
|
||||
force: options?.force || false,
|
||||
},
|
||||
];
|
||||
} );
|
||||
};
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { RegisteredBlocks } from './types';
|
||||
|
||||
const registeredBlocks: RegisteredBlocks = {
|
||||
'woocommerce/checkout-fields-block': [],
|
||||
'woocommerce/checkout-totals-block': [],
|
||||
'woocommerce/checkout-contact-information-block': [
|
||||
{
|
||||
block: 'core/paragraph',
|
||||
component: null,
|
||||
force: false,
|
||||
},
|
||||
],
|
||||
'woocommerce/checkout-shipping-address-block': [
|
||||
{
|
||||
block: 'core/paragraph',
|
||||
component: null,
|
||||
force: false,
|
||||
},
|
||||
],
|
||||
'woocommerce/checkout-billing-address-block': [
|
||||
{
|
||||
block: 'core/paragraph',
|
||||
component: null,
|
||||
force: false,
|
||||
},
|
||||
],
|
||||
'woocommerce/checkout-shipping-methods-block': [
|
||||
{
|
||||
block: 'core/paragraph',
|
||||
component: null,
|
||||
force: false,
|
||||
},
|
||||
],
|
||||
'woocommerce/checkout-payment-methods-block': [
|
||||
{
|
||||
block: 'core/paragraph',
|
||||
component: null,
|
||||
force: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export { registeredBlocks };
|
|
@ -1,7 +1,11 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getRegisteredBlocks, registerCheckoutBlock } from '../index';
|
||||
import {
|
||||
getRegisteredBlocks,
|
||||
registerCheckoutBlock,
|
||||
innerBlockAreas,
|
||||
} from '../index';
|
||||
|
||||
describe( 'checkout blocks registry', () => {
|
||||
const component = () => {
|
||||
|
@ -39,22 +43,27 @@ describe( 'checkout blocks registry', () => {
|
|||
} );
|
||||
|
||||
describe( 'getRegisteredBlocks', () => {
|
||||
const invokeTest = ( areas ) => () => {
|
||||
return getRegisteredBlocks( areas );
|
||||
};
|
||||
it( 'gets an empty array when checkout area has no registered blocks', () => {
|
||||
expect( getRegisteredBlocks( 'fields' ) ).toEqual( [] );
|
||||
expect(
|
||||
getRegisteredBlocks( innerBlockAreas.CHECKOUT_FIELDS )
|
||||
).toEqual( [] );
|
||||
} );
|
||||
it( 'throws an error if the area is not defined', () => {
|
||||
expect( invokeTest( 'non-existent-area' ) ).toThrowError( /area/ );
|
||||
it( 'gets an empty array when the area is not defined', () => {
|
||||
expect( getRegisteredBlocks( 'not-defined' ) ).toEqual( [] );
|
||||
} );
|
||||
it( 'gets a block that was successfully registered', () => {
|
||||
registerCheckoutBlock( 'test/block-name', {
|
||||
areas: [ 'fields' ],
|
||||
areas: [ innerBlockAreas.CHECKOUT_FIELDS ],
|
||||
component,
|
||||
} );
|
||||
expect( getRegisteredBlocks( 'fields' ) ).toEqual( [
|
||||
'test/block-name',
|
||||
expect(
|
||||
getRegisteredBlocks( innerBlockAreas.CHECKOUT_FIELDS )
|
||||
).toEqual( [
|
||||
{
|
||||
block: 'test/block-name',
|
||||
component,
|
||||
force: false,
|
||||
},
|
||||
] );
|
||||
} );
|
||||
} );
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { LazyExoticComponent } from 'react';
|
||||
import type { BlockConfiguration } from '@wordpress/blocks';
|
||||
|
||||
export enum innerBlockAreas {
|
||||
CHECKOUT_FIELDS = 'woocommerce/checkout-fields-block',
|
||||
CHECKOUT_TOTALS = 'woocommerce/checkout-totals-block',
|
||||
CONTACT_INFORMATION = 'woocommerce/checkout-contact-information-block',
|
||||
SHIPPING_ADDRESS = 'woocommerce/checkout-shipping-address-block',
|
||||
BILLING_ADDRESS = 'woocommerce/checkout-billing-address-block',
|
||||
SHIPPING_METHODS = 'woocommerce/checkout-shipping-methods-block',
|
||||
PAYMENT_METHODS = 'woocommerce/checkout-payment-methods-block',
|
||||
}
|
||||
|
||||
export type RegisteredBlockComponent =
|
||||
| LazyExoticComponent< React.ComponentType< unknown > >
|
||||
| ( () => JSX.Element | null );
|
||||
|
||||
export type RegisteredBlock = {
|
||||
block: string;
|
||||
component: RegisteredBlockComponent | null;
|
||||
force: boolean;
|
||||
};
|
||||
|
||||
export type RegisteredBlocks = Record<
|
||||
innerBlockAreas,
|
||||
Array< RegisteredBlock >
|
||||
>;
|
||||
|
||||
export type CheckoutBlockOptions = {
|
||||
// This is a component to render on the frontend in place of this block, when used.
|
||||
component: RegisteredBlockComponent;
|
||||
// Area(s) to add the block to. This can be a single area (string) or an array of areas.
|
||||
areas: Array< innerBlockAreas >;
|
||||
// Should this block be forced? If true, it cannot be removed from the editor interface, and will be rendered in defined areas automatically.
|
||||
force?: boolean;
|
||||
// Standard block configuration object. If not passed, the block will not be registered with WordPress and must be done manually.
|
||||
configuration?: Partial< BlockConfiguration >;
|
||||
};
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isObject } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { innerBlockAreas } from './types';
|
||||
|
||||
/**
|
||||
* Asserts that an option is of the given type. Otherwise, throws an error.
|
||||
*
|
||||
* @throws Will throw an error if the type of the option doesn't match the expected type.
|
||||
*/
|
||||
export const assertType = (
|
||||
optionName: string,
|
||||
option: unknown,
|
||||
expectedType: unknown
|
||||
): void => {
|
||||
const actualType = typeof option;
|
||||
if ( actualType !== expectedType ) {
|
||||
throw new Error(
|
||||
`Incorrect value for the ${ optionName } argument when registering a checkout block. It was a ${ actualType }, but must be a ${ expectedType }.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation to ensure an area exists.
|
||||
*/
|
||||
export const assertValidArea = ( area: string ): void => {
|
||||
if (
|
||||
! Object.values( innerBlockAreas ).includes( area as innerBlockAreas )
|
||||
) {
|
||||
throw new Error(
|
||||
`Incorrect value for the "area" argument. It was a ${ area }, but must be one of ${ Object.values(
|
||||
innerBlockAreas
|
||||
).join( ', ' ) }.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the block name.
|
||||
*
|
||||
* @throws Will throw an error if the block name is invalid.
|
||||
*/
|
||||
export const assertBlockName = ( blockName: string ): void => {
|
||||
assertType( 'blockName', blockName, 'string' );
|
||||
|
||||
if ( ! blockName ) {
|
||||
throw new Error(
|
||||
`Value for the blockName argument must not be empty.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that an option is of the given type. Otherwise, throws an error.
|
||||
*
|
||||
* @throws Will throw an error if the type of the option doesn't match the expected type.
|
||||
*/
|
||||
export const assertOption = (
|
||||
options: Record< string, unknown >,
|
||||
optionName: string,
|
||||
expectedType: 'array' | 'object' | 'string' | 'boolean' | 'number'
|
||||
): void => {
|
||||
const actualType = typeof options[ optionName ];
|
||||
|
||||
if ( expectedType === 'array' ) {
|
||||
if ( ! Array.isArray( options[ optionName ] ) ) {
|
||||
throw new Error(
|
||||
`Incorrect value for the ${ optionName } argument when registering a checkout block component. It was a ${ actualType }, but must be an array.`
|
||||
);
|
||||
}
|
||||
} else if ( actualType !== expectedType ) {
|
||||
throw new Error(
|
||||
`Incorrect value for the ${ optionName } argument when registering a checkout block component. It was a ${ actualType }, but must be a ${ expectedType }.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that an option is a valid react element or lazy callback. Otherwise, throws an error.
|
||||
*
|
||||
* @throws Will throw an error if the type of the option doesn't match the expected type.
|
||||
*/
|
||||
export const assertBlockComponent = (
|
||||
options: Record< string, unknown >,
|
||||
optionName: string
|
||||
): void => {
|
||||
const optionValue = options[ optionName ];
|
||||
|
||||
if ( optionValue ) {
|
||||
if ( typeof optionValue === 'function' ) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isObject( optionValue ) &&
|
||||
optionValue.$$typeof &&
|
||||
optionValue.$$typeof === Symbol.for( 'react.lazy' )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Incorrect value for the ${ optionName } argument when registering a block component. Component must be a valid React Element or Lazy callback.`
|
||||
);
|
||||
};
|
|
@ -255,10 +255,10 @@ abstract class AbstractBlock {
|
|||
/**
|
||||
* Get block attributes.
|
||||
*
|
||||
* @return array|null;
|
||||
* @return array;
|
||||
*/
|
||||
protected function get_block_type_attributes() {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -46,6 +46,7 @@ final class BlockTypesController {
|
|||
*/
|
||||
protected function init() {
|
||||
add_action( 'init', array( $this, 'register_blocks' ) );
|
||||
add_filter( 'render_block', array( $this, 'add_data_attributes' ), 10, 2 );
|
||||
add_action( 'woocommerce_login_form_end', array( $this, 'redirect_to_field' ) );
|
||||
add_filter( 'widget_types_to_hide_from_legacy_widget_block', array( $this, 'hide_legacy_widgets_with_block_equivalent' ) );
|
||||
}
|
||||
|
@ -66,6 +67,57 @@ final class BlockTypesController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add data- attributes to blocks when rendered if the block is under the woocommerce/ namespace.
|
||||
*
|
||||
* @param string $content Block content.
|
||||
* @param array $block Parsed block data.
|
||||
* @return string
|
||||
*/
|
||||
public function add_data_attributes( $content, $block ) {
|
||||
$block_name = $block['blockName'];
|
||||
$block_namespace = strtok( $block_name, '/' );
|
||||
|
||||
/**
|
||||
* WooCommerce Blocks Namespaces
|
||||
*
|
||||
* This hook defines which block namespaces should have block name and attribute data- attributes appended on render.
|
||||
*
|
||||
* @param array $allowed_namespaces List of namespaces.
|
||||
*/
|
||||
$allowed_namespaces = array_merge( [ 'woocommerce', 'woocommerce-checkout' ], (array) apply_filters( '__experimental_woocommerce_blocks_add_data_attributes_to_namespace', [] ) );
|
||||
|
||||
/**
|
||||
* WooCommerce Blocks Block Names
|
||||
*
|
||||
* This hook defines which block names should have block name and attribute data- attributes appended on render.
|
||||
*
|
||||
* @param array $allowed_namespaces List of namespaces.
|
||||
*/
|
||||
$allowed_blocks = (array) apply_filters( '__experimental_woocommerce_blocks_add_data_attributes_to_block', [] );
|
||||
|
||||
if ( ! in_array( $block_namespace, $allowed_namespaces, true ) && ! in_array( $block_name, $allowed_blocks, true ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$attributes = (array) $block['attrs'];
|
||||
$escaped_data_attributes = [
|
||||
'data-block-name="' . esc_attr( $block['blockName'] ) . '"',
|
||||
];
|
||||
|
||||
foreach ( $attributes as $key => $value ) {
|
||||
if ( is_bool( $value ) ) {
|
||||
$value = $value ? 'true' : 'false';
|
||||
}
|
||||
if ( ! is_scalar( $value ) ) {
|
||||
$value = wp_json_encode( $value );
|
||||
}
|
||||
$escaped_data_attributes[] = 'data-' . esc_attr( strtolower( preg_replace( '/(?<!\ )[A-Z]/', '-$0', $key ) ) ) . '="' . esc_attr( $value ) . '"';
|
||||
}
|
||||
|
||||
return preg_replace( '/^<div /', '<div ' . implode( ' ', $escaped_data_attributes ) . ' ', trim( $content ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a redirect field to the login form so blocks can redirect users after login.
|
||||
*/
|
||||
|
@ -171,19 +223,6 @@ final class BlockTypesController {
|
|||
'product-tag-list',
|
||||
'product-stock-indicator',
|
||||
'product-add-to-cart',
|
||||
'checkout-fields-block',
|
||||
'checkout-totals-block',
|
||||
'checkout-billing-address-block',
|
||||
'checkout-actions-block',
|
||||
'checkout-contact-information-block',
|
||||
'checkout-order-note-block',
|
||||
'checkout-order-summary-block',
|
||||
'checkout-payment-block',
|
||||
'checkout-shipping-address-block',
|
||||
'checkout-shipping-methods-block',
|
||||
'checkout-express-payment-block',
|
||||
'checkout-terms-block',
|
||||
'checkout-sample-block',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue