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:
Mike Jolley 2021-09-03 14:25:09 +01:00 committed by GitHub
parent 7e7cf87dc0
commit b385d4005c
19 changed files with 624 additions and 316 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 ],

View File

@ -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>
);
};

View File

@ -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,
},

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 ],

View File

@ -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 );
};

View File

@ -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';

View File

@ -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,
},
];
} );
};

View File

@ -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 };

View File

@ -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,
},
] );
} );
} );

View File

@ -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 >;
};

View File

@ -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.`
);
};

View File

@ -255,10 +255,10 @@ abstract class AbstractBlock {
/**
* Get block attributes.
*
* @return array|null;
* @return array;
*/
protected function get_block_type_attributes() {
return null;
return [];
}
/**

View File

@ -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',
];
}
}