diff --git a/assets/js/blocks/cart-checkout-shared/editor-utils.ts b/assets/js/blocks/cart-checkout-shared/editor-utils.ts index 32b9d44eb98..aa72240f82a 100644 --- a/assets/js/blocks/cart-checkout-shared/editor-utils.ts +++ b/assets/js/blocks/cart-checkout-shared/editor-utils.ts @@ -2,6 +2,9 @@ * External dependencies */ import { getBlockTypes } from '@wordpress/blocks'; +import { applyCheckoutFilter } from '@woocommerce/blocks-checkout'; +import { CART_STORE_KEY } from '@woocommerce/block-data'; +import { select } from '@wordpress/data'; // List of core block types to allow in inner block areas. const coreBlockTypes = [ 'core/paragraph', 'core/image', 'core/separator' ]; @@ -9,11 +12,35 @@ const coreBlockTypes = [ 'core/paragraph', 'core/image', 'core/separator' ]; /** * Gets a list of allowed blocks types under a specific parent block type. */ -export const getAllowedBlocks = ( block: string ): string[] => [ - ...getBlockTypes() - .filter( ( blockType ) => - ( blockType?.parent || [] ).includes( block ) - ) - .map( ( { name } ) => name ), - ...coreBlockTypes, -]; +export const getAllowedBlocks = ( block: string ): string[] => { + const additionalCartCheckoutInnerBlockTypes = applyCheckoutFilter( { + filterName: 'additionalCartCheckoutInnerBlockTypes', + defaultValue: [], + extensions: select( CART_STORE_KEY ).getCartData().extensions, + arg: { block }, + validation: ( value ) => { + if ( + Array.isArray( value ) && + value.every( ( item ) => typeof item === 'string' ) + ) { + return true; + } + throw new Error( + 'allowedBlockTypes filters must return an array of strings.' + ); + }, + } ); + + // Convert to set here so that we remove duplicated block types. + return Array.from( + new Set( [ + ...getBlockTypes() + .filter( ( blockType ) => + ( blockType?.parent || [] ).includes( block ) + ) + .map( ( { name } ) => name ), + ...coreBlockTypes, + ...additionalCartCheckoutInnerBlockTypes, + ] ) + ); +}; diff --git a/docs/third-party-developers/extensibility/checkout-block/available-filters.md b/docs/third-party-developers/extensibility/checkout-block/available-filters.md index 33fc5fc791b..d615b46bcdd 100644 --- a/docs/third-party-developers/extensibility/checkout-block/available-filters.md +++ b/docs/third-party-developers/extensibility/checkout-block/available-filters.md @@ -9,11 +9,14 @@ - [Proceed to Checkout Button Label](#proceed-to-checkout-button-label) - [Proceed to Checkout Button Link](#proceed-to-checkout-button-link) - [Place Order Button Label](#place-order-button-label) +- [Additional Cart Checkout inner block types](#additional-cart-checkout-inner-block-types) - [Examples](#examples) - - [Changing the wording and the link on the "Proceed to Checkout" button when a specific item is in the Cart](#changing-the-wording-and-the-link-on-the--proceed-to-checkout--button-when-a-specific-item-is-in-the-cart) + - [Changing the wording and the link on the "Proceed to Checkout" button when a specific item is in the Cart](#changing-the-wording-and-the-link-on-the-proceed-to-checkout-button-when-a-specific-item-is-in-the-cart) + - [Allowing blocks in specific areas in the Cart and Checkout blocks](#allowing-blocks-in-specific-areas-in-the-cart-and-checkout-blocks) - [Changing the wording of the Totals label in the Mini Cart, Cart and Checkout](#changing-the-wording-of-the-totals-label-in-the-mini-cart-cart-and-checkout) - [Changing the format of the item's single price](#changing-the-format-of-the-items-single-price) - [Change the name of a coupon](#change-the-name-of-a-coupon) + - [Prevent a snackbar notice from appearing for coupons](#prevent-a-snackbar-notice-from-appearing-for-coupons) - [Hide the "Remove item" link on a cart item](#hide-the-remove-item-link-on-a-cart-item) - [Change the label of the Place Order button](#change-the-label-of-the-place-order-button) - [Troubleshooting](#troubleshooting) @@ -98,7 +101,7 @@ CartCoupon { The Cart block contains a button which is labelled 'Proceed to Checkout' by default. It can be changed using the following filter. | Filter name | Description | Return type | -|--------------------------------|-----------------------------------------------------| ----------- | +| ------------------------------ | --------------------------------------------------- | ----------- | | `proceedToCheckoutButtonLabel` | The wanted label of the Proceed to Checkout button. | `string` | ## Proceed to Checkout Button Link @@ -106,7 +109,7 @@ The Cart block contains a button which is labelled 'Proceed to Checkout' by defa The Cart block contains a button which is labelled 'Proceed to Checkout' and links to the Checkout page by default, but can be changed using the following filter. This filter has the current cart passed to it in the third parameter. | Filter name | Description | Return type | -|-------------------------------|-------------------------------------------------------------| ----------- | +| ----------------------------- | ----------------------------------------------------------- | ----------- | | `proceedToCheckoutButtonLink` | The URL that the Proceed to Checkout button should link to. | `string` | ## Place Order Button Label @@ -117,6 +120,20 @@ The Checkout block contains a button which is labelled 'Place Order' by default, | ----------------------- | ------------------------------------------- | ----------- | | `placeOrderButtonLabel` | The wanted label of the Place Order button. | `string` | +## Additional Cart Checkout inner block types + +The Cart and Checkout blocks are made up of inner blocks. These inner blocks areas allow certain block types to be added as children. By default, only `core/paragraph`, `core/image`, and `core/separator` are available to add. + +By using the `additionalCartCheckoutInnerBlockTypes` filter it is possible to add items to this array to control what the editor can insert into an inner block. + +This filter is called once for each inner block area, so it is possible to be very granular when determining what blocks can be added where. See the [Allowing blocks in specific areas in the Cart and Checkout blocks.](#allowing-blocks-in-specific-areas-in-the-cart-and-checkout-blocks) example for more information. + +| Filter name | Description | Return type | +| --------------------------------------- | ---------------------------------------- | ------------- | +| `allowedBlockTypes` | The new array of allowwed block types. | `string[]` | +| ------------------- | ---------------------------------------- | ------------- | +| `additionalCartCheckoutInnerBlockTypes` | The new array of allowwed block types. | `string[]` | + ## Examples ### Changing the wording and the link on the "Proceed to Checkout" button when a specific item is in the Cart @@ -154,9 +171,37 @@ registerCheckoutFilters( 'sunglasses-store-extension', { } ); ``` -| Before | After | -|-------------------------------------------------------------------------------------------------------------------------------------------| ----- | -| image | image | +| Before | After | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| image | image | + +### Allowing blocks in specific areas in the Cart and Checkout blocks + +Let's suppose we want to allow the editor to add some blocks in specific places in the Cart and Checkout blocks. + +1. Allow `core/table` to be inserted in the Shipping Address block in the Checkout. +2. Allow `core/quote` to be inserted in every block area in the Cart and Checkout blocks. + +In our extension we could register a filter satisfy both of these conditions like so: + +```tsx +registerCheckoutFilters( 'newsletter-plugin', { + allowedBlockTypes: ( value, extensions, { block } ) => { + // Remove the ability to add `core/separator` + value = value.filter( ( blockName ) => blockName !== 'core/separator' ); + + // Add core/quote to any inner block area. + value.push( 'core/quote' ); + + // If the block we're checking is `woocommerce/checkout-shipping-address-block then allow `core/table`. + if ( block === 'woocommerce/checkout-shipping-address-block' ) { + value.push( 'core/table' ); + } + + return value; + }, +} ); +``` ### Changing the wording of the Totals label in the Mini Cart, Cart and Checkout @@ -324,4 +369,3 @@ The error will also be shown in your console. 🐞 Found a mistake, or have a suggestion? [Leave feedback about this document here.](https://github.com/woocommerce/woocommerce-blocks/issues/new?assignees=&labels=type%3A+documentation&template=--doc-feedback.md&title=Feedback%20on%20./docs/third-party-developers/extensibility/checkout-block/available-filters.md) - diff --git a/tests/e2e/specs/backend/cart.test.js b/tests/e2e/specs/backend/cart.test.js index 91223c4b411..5e177dd4f99 100644 --- a/tests/e2e/specs/backend/cart.test.js +++ b/tests/e2e/specs/backend/cart.test.js @@ -7,6 +7,7 @@ import { switchUserToAdmin, searchForBlock, openGlobalBlockInserter, + insertBlock, } from '@wordpress/e2e-test-utils'; import { findLabelWithText, @@ -67,6 +68,70 @@ describe( `${ block.name } Block`, () => { expect( button ).toHaveLength( 0 ); } ); + it( 'inner blocks can be added/removed by filters', async () => { + // Begin by removing the block. + await selectBlockByName( block.slug ); + const options = await page.$x( + '//div[@class="block-editor-block-toolbar"]//button[@aria-label="Options"]' + ); + await options[ 0 ].click(); + const removeButton = await page.$x( + '//button[contains(., "Remove Cart")]' + ); + await removeButton[ 0 ].click(); + // Expect block to have been removed. + await expect( page ).not.toMatchElement( block.class ); + + // Register a checkout filter to allow `core/table` block in the Checkout block's inner blocks, add + // core/audio into the woocommerce/cart-order-summary-block and remove core/paragraph from all Cart inner + // blocks. + await page.evaluate( + "wc.blocksCheckout.registerCheckoutFilters( 'woo-test-namespace'," + + '{ additionalCartCheckoutInnerBlockTypes: ( value, extensions, { block } ) => {' + + " value.push('core/table');" + + " if ( block === 'woocommerce/cart-order-summary-block' ) {" + + " value.push( 'core/audio' );" + + ' }' + + ' return value;' + + '}' + + '}' + + ');' + ); + + await insertBlock( block.name ); + + // Select the shipping address block and try to insert a block. Check the Table block is available. + await selectBlockByName( 'woocommerce/cart-order-summary-block' ); + await page.waitForTimeout( 1000 ); + const addBlockButton = await page.waitForXPath( + '//div[@data-type="woocommerce/cart-order-summary-block"]//button[@aria-label="Add block"]' + ); + await addBlockButton.click(); + const tableButton = await page.waitForXPath( + '//*[@role="option" and contains(., "Table")]' + ); + const audioButton = await page.waitForXPath( + '//*[@role="option" and contains(., "Audio")]' + ); + expect( tableButton ).not.toBeNull(); + expect( audioButton ).not.toBeNull(); + + // // Now check the filled cart block and expect only the Table block to be available there. + await selectBlockByName( 'woocommerce/filled-cart-block' ); + const filledCartAddBlockButton = await page.waitForXPath( + '//div[@data-type="woocommerce/filled-cart-block"]//button[@aria-label="Add block"]' + ); + await filledCartAddBlockButton.click(); + const filledCartTableButton = await page.waitForXPath( + '//*[@role="option" and contains(., "Table")]' + ); + const filledCartAudioButton = await page.$x( + '//*[@role="option" and contains(., "Audio")]' + ); + expect( filledCartTableButton ).not.toBeNull(); + expect( filledCartAudioButton ).toHaveLength( 0 ); + } ); + it( 'renders without crashing', async () => { await expect( page ).toRenderBlock( block ); await expect( page ).toRenderBlock( filledCartBlock ); diff --git a/tests/e2e/specs/backend/checkout.test.js b/tests/e2e/specs/backend/checkout.test.js index cf661d8e429..8d30fc18881 100644 --- a/tests/e2e/specs/backend/checkout.test.js +++ b/tests/e2e/specs/backend/checkout.test.js @@ -5,6 +5,7 @@ import { openDocumentSettingsSidebar, switchUserToAdmin, openGlobalBlockInserter, + insertBlock, } from '@wordpress/e2e-test-utils'; import { findLabelWithText, @@ -52,6 +53,72 @@ describe( `${ block.name } Block`, () => { expect( button ).toHaveLength( 0 ); } ); + it( 'inner blocks can be added/removed by filters', async () => { + // Begin by removing the block. + await selectBlockByName( block.slug ); + const options = await page.$x( + '//div[@class="block-editor-block-toolbar"]//button[@aria-label="Options"]' + ); + await options[ 0 ].click(); + const removeButton = await page.$x( + '//button[contains(., "Remove Checkout")]' + ); + await removeButton[ 0 ].click(); + // Expect block to have been removed. + await expect( page ).not.toMatchElement( block.class ); + + // Register a checkout filter to allow `core/table` block in the Checkout block's inner blocks. + await page.evaluate( + "wc.blocksCheckout.registerCheckoutFilters( 'woo-test-namespace'," + + '{ additionalCartCheckoutInnerBlockTypes: ( value, extensions, { block } ) => {' + + " value.push('core/table');" + + " if ( block === 'woocommerce/checkout-shipping-address-block' ) {" + + " value.push( 'core/audio' );" + + ' }' + + ' return value;' + + '}' + + '}' + + ');' + ); + + await insertBlock( block.name ); + + // Select the shipping address block and try to insert a block. Check the Table block is available. + await selectBlockByName( + 'woocommerce/checkout-shipping-address-block' + ); + const addBlockButton = await page.waitForXPath( + '//div[@data-type="woocommerce/checkout-shipping-address-block"]//button[@aria-label="Add block"]' + ); + expect( addBlockButton ).not.toBeNull(); + await addBlockButton.click(); + const tableButton = await page.waitForXPath( + '//*[@role="option" and contains(., "Table")]' + ); + const audioButton = await page.waitForXPath( + '//*[@role="option" and contains(., "Audio")]' + ); + expect( tableButton ).not.toBeNull(); + expect( audioButton ).not.toBeNull(); + + // Now check the contact information block and expect only the Table block to be available there. + await selectBlockByName( + 'woocommerce/checkout-contact-information-block' + ); + const contactInformationAddBlockButton = await page.waitForXPath( + '//div[@data-type="woocommerce/checkout-contact-information-block"]//button[@aria-label="Add block"]' + ); + await contactInformationAddBlockButton.click(); + const contactInformationTableButton = await page.waitForXPath( + '//*[@role="option" and contains(., "Table")]' + ); + const contactInformationAudioButton = await page.$x( + '//*[@role="option" and contains(., "Audio")]' + ); + expect( contactInformationTableButton ).not.toBeNull(); + expect( contactInformationAudioButton ).toHaveLength( 0 ); + } ); + it( 'renders without crashing', async () => { await expect( page ).toRenderBlock( block ); } );