diff --git a/assets/js/atomic/utils/blocks-registration-manager/block-registration-strategy.ts b/assets/js/atomic/utils/blocks-registration-manager/block-registration-strategy.ts new file mode 100644 index 00000000000..cfc7171fc1b --- /dev/null +++ b/assets/js/atomic/utils/blocks-registration-manager/block-registration-strategy.ts @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { + BlockConfiguration, + registerBlockType, + unregisterBlockType, + registerBlockVariation, + unregisterBlockVariation, + BlockVariation, + BlockAttributes, +} from '@wordpress/blocks'; + +export interface BlockRegistrationStrategy { + register( + blockNameOrMetadata: string | Partial< BlockConfiguration >, + blockSettings: Partial< BlockConfiguration > + ): boolean; + unregister( blockName: string, variationName?: string ): boolean; +} + +export class BlockTypeStrategy implements BlockRegistrationStrategy { + register( + blockNameOrMetadata: string | Partial< BlockConfiguration >, + blockSettings: Partial< BlockConfiguration > + ): boolean { + return Boolean( + // @ts-expect-error: `registerBlockType` is typed in WordPress core + registerBlockType( blockNameOrMetadata, blockSettings ) + ); + } + + unregister( blockName: string ): boolean { + return Boolean( unregisterBlockType( blockName ) ); + } +} + +// Strategy for BlockVariation +export class BlockVariationStrategy implements BlockRegistrationStrategy { + register( + blockName: string, + blockSettings: Partial< BlockConfiguration > + ): boolean { + return Boolean( + registerBlockVariation( + blockName, + blockSettings as BlockVariation< BlockAttributes > + ) + ); + } + + unregister( blockName: string, variationName: string ): boolean { + return Boolean( unregisterBlockVariation( blockName, variationName ) ); + } +} diff --git a/assets/js/atomic/utils/blocks-registration-manager/blocks-registration-manager.ts b/assets/js/atomic/utils/blocks-registration-manager/blocks-registration-manager.ts new file mode 100644 index 00000000000..79446bd91e3 --- /dev/null +++ b/assets/js/atomic/utils/blocks-registration-manager/blocks-registration-manager.ts @@ -0,0 +1,168 @@ +/** + * Internal dependencies + */ +import { + TemplateChangeDetector, + TemplateChangeDetectorObserver, +} from './template-change-detector'; +import { + BlockRegistrationStrategy, + BlockTypeStrategy, + BlockVariationStrategy, +} from './block-registration-strategy'; +import { BLOCKS_WITH_RESTRICTION } from './blocks-with-restriction'; + +/** + * Manages the registration and unregistration of blocks based on template or page restrictions. + * + * This class implements the TemplateChangeDetectorObserver interface and is responsible for managing the registration and unregistration of blocks based on the restrictions defined in the BLOCKS_WITH_RESTRICTION constant. + * + * The class maintains a list of unregistered blocks and uses a block registration strategy to register and unregister blocks as needed. The strategy used depends on whether the block is a variation block or a regular block. + * + * The `run` method is the main entry point for the class. It is called with a TemplateChangeDetector object and registers and unregisters blocks based on the current template and whether the editor is in post or page mode. + */ +export class BlockRegistrationManager + implements TemplateChangeDetectorObserver +{ + private unregisteredBlocks: string[] = []; + private blockRegistrationStrategy: BlockRegistrationStrategy; + + constructor() { + this.blockRegistrationStrategy = new BlockTypeStrategy(); + } + + /** + * Determines whether a block should be registered based on the current template or page. + * + * This method checks whether a block with restrictions should be registered based on the current template ID and + * whether the editor is in post or page mode. It checks whether the current template ID starts with any of the + * allowed templates or template parts for the block, and whether the block is available in the post or page editor. + * + * @param {Object} params - The parameters for the method. + * @param {string} params.blockWithRestrictionName - The name of the block with restrictions. + * @param {string} params.currentTemplateId - The ID of the current template. + * @param {boolean} params.isPostOrPage - Whether the editor is in a post or page. + * @return {boolean} True if the block should be registered, false otherwise. + */ + private shouldBlockBeRegistered( { + blockWithRestrictionName, + currentTemplateId, + isPostOrPage, + }: { + blockWithRestrictionName: string; + currentTemplateId: string; + isPostOrPage: boolean; + } ) { + const { + allowedTemplates, + allowedTemplateParts, + availableInPostOrPageEditor, + } = BLOCKS_WITH_RESTRICTION[ blockWithRestrictionName ]; + const shouldBeAvailableOnTemplate = Object.keys( + allowedTemplates + ).some( ( allowedTemplate ) => + currentTemplateId.startsWith( allowedTemplate ) + ); + const shouldBeAvailableOnTemplatePart = Object.keys( + allowedTemplateParts + ).some( ( allowedTemplate ) => + currentTemplateId.startsWith( allowedTemplate ) + ); + const shouldBeAvailableOnPostOrPageEditor = + isPostOrPage && availableInPostOrPageEditor; + + return ( + shouldBeAvailableOnTemplate || + shouldBeAvailableOnTemplatePart || + shouldBeAvailableOnPostOrPageEditor + ); + } + + /** + * Unregisters blocks before entering a restricted area based on the current template or page/post. + * + * This method iterates over all blocks with restrictions and unregisters them if they should not be registered + * based on the current template ID and whether the editor is in a post or page. It uses a block registration + * strategy to unregister the blocks, which depends on whether the block is a variation block or a regular block. + * + * @param {Object} params - The parameters for the method. + * @param {string} params.currentTemplateId - The ID of the current template. + * @param {boolean} params.isPostOrPage - Whether the editor is in post or page mode. + */ + unregisterBlocksBeforeEnteringRestrictedArea( { + currentTemplateId, + isPostOrPage, + }: { + currentTemplateId: string; + isPostOrPage: boolean; + } ) { + for ( const blockWithRestrictionName of Object.keys( + BLOCKS_WITH_RESTRICTION + ) ) { + if ( + this.shouldBlockBeRegistered( { + blockWithRestrictionName, + currentTemplateId, + isPostOrPage, + } ) + ) { + continue; + } + this.blockRegistrationStrategy = BLOCKS_WITH_RESTRICTION[ + blockWithRestrictionName + ].isVariationBlock + ? new BlockVariationStrategy() + : new BlockTypeStrategy(); + + this.blockRegistrationStrategy.unregister( + blockWithRestrictionName + ); + this.unregisteredBlocks.push( blockWithRestrictionName ); + } + } + + /** + * Registers blocks after leaving a restricted area. + * + * This method iterates over all unregistered blocks and registers them if they are not restricted in the current context. + * It uses a block registration strategy to register the blocks, which depends on whether the block is a variation block or a regular block. + * If the block is successfully registered, it is removed from the list of unregistered blocks. + */ + registerBlocksAfterLeavingRestrictedArea() { + for ( const unregisteredBlockName of this.unregisteredBlocks ) { + const restrictedBlockData = + BLOCKS_WITH_RESTRICTION[ unregisteredBlockName ]; + this.blockRegistrationStrategy = BLOCKS_WITH_RESTRICTION[ + unregisteredBlockName + ].isVariationBlock + ? new BlockVariationStrategy() + : new BlockTypeStrategy(); + const isBlockRegistered = this.blockRegistrationStrategy.register( + restrictedBlockData.blockMetadata, + restrictedBlockData.blockSettings + ); + this.unregisteredBlocks = isBlockRegistered + ? this.unregisteredBlocks.filter( + ( blockName ) => blockName !== unregisteredBlockName + ) + : this.unregisteredBlocks; + } + } + + /** + * Runs the block registration manager. + * + * This method is the main entry point for the block registration manager. It is called with a TemplateChangeDetector object, + * and registers and unregisters blocks based on the current template and whether the editor is in a post or page. + * + * @param {TemplateChangeDetector} templateChangeDetector - The template change detector object. + */ + run( templateChangeDetector: TemplateChangeDetector ) { + this.registerBlocksAfterLeavingRestrictedArea(); + this.unregisterBlocksBeforeEnteringRestrictedArea( { + currentTemplateId: + templateChangeDetector.getCurrentTemplateId() || '', + isPostOrPage: templateChangeDetector.getIsPostOrPage(), + } ); + } +} diff --git a/assets/js/atomic/utils/blocks-registration-manager/blocks-with-restriction.ts b/assets/js/atomic/utils/blocks-registration-manager/blocks-with-restriction.ts new file mode 100644 index 00000000000..7b3782b99c7 --- /dev/null +++ b/assets/js/atomic/utils/blocks-registration-manager/blocks-with-restriction.ts @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { BlockConfiguration } from '@wordpress/blocks'; +import { ProductGalleryBlockSettings } from '@woocommerce/blocks/product-gallery/settings'; + +/** + * Internal dependencies + */ +import productGalleryBlockMetadata from '../../../blocks/product-gallery/block.json'; + +export interface BlocksWithRestriction { + [ key: string ]: { + blockMetadata: Partial< BlockConfiguration >; + blockSettings: Partial< BlockConfiguration >; + allowedTemplates: { + [ key: string ]: boolean; + }; + allowedTemplateParts: { + [ key: string ]: boolean; + }; + availableInPostOrPageEditor: boolean; + isVariationBlock: boolean; + }; +} + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core +export const BLOCKS_WITH_RESTRICTION: BlocksWithRestriction = { + [ productGalleryBlockMetadata.name ]: { + blockMetadata: productGalleryBlockMetadata, + blockSettings: ProductGalleryBlockSettings, + allowedTemplates: { + 'single-product': true, + }, + allowedTemplateParts: { + 'product-gallery': true, + }, + availableInPostOrPageEditor: false, + isVariationBlock: false, + }, +}; diff --git a/assets/js/atomic/utils/blocks-registration-manager/index.ts b/assets/js/atomic/utils/blocks-registration-manager/index.ts new file mode 100644 index 00000000000..e24334f257f --- /dev/null +++ b/assets/js/atomic/utils/blocks-registration-manager/index.ts @@ -0,0 +1,16 @@ +/** + * External dependencies + */ +import domReady from '@wordpress/dom-ready'; + +/** + * Internal dependencies + */ +import { BlockRegistrationManager } from './blocks-registration-manager'; +import { TemplateChangeDetector } from './template-change-detector'; + +domReady( () => { + const templateChangeDetector = new TemplateChangeDetector(); + const blockRegistrationManager = new BlockRegistrationManager(); + templateChangeDetector.add( blockRegistrationManager ); +} ); diff --git a/assets/js/atomic/utils/blocks-registration-manager/template-change-detector.ts b/assets/js/atomic/utils/blocks-registration-manager/template-change-detector.ts new file mode 100644 index 00000000000..344fff892cb --- /dev/null +++ b/assets/js/atomic/utils/blocks-registration-manager/template-change-detector.ts @@ -0,0 +1,115 @@ +/** + * External dependencies + */ +import { subscribe, select } from '@wordpress/data'; +import { isNumber } from '@woocommerce/types'; + +interface TemplateChangeDetectorSubject { + add( observer: TemplateChangeDetectorObserver ): void; + getPreviousTemplateId(): string | undefined; + getCurrentTemplateId(): string | undefined; + notify(): void; +} + +export interface TemplateChangeDetectorObserver { + run( subject: TemplateChangeDetectorSubject ): void; +} + +/** + * This class implements the TemplateChangeDetectorSubject interface and is responsible for detecting changes in the + * current template or page and notifying any observers of these changes. It maintains a list of observers and provides methods + * to add observers and notify them of changes. + * + * The class also provides methods to get the previous and current template IDs and whether the editor is in a post or page. + * + * The `checkIfTemplateHasChangedAndNotifySubscribers` method is the main method of the class. It checks if the current + * template has changed and, if so, notifies all observers. + */ +export class TemplateChangeDetector implements TemplateChangeDetectorSubject { + private previousTemplateId: string | undefined; + private currentTemplateId: string | undefined; + private isPostOrPage: boolean; + + private observers: TemplateChangeDetectorObserver[] = []; + + constructor() { + this.isPostOrPage = false; + subscribe( () => { + this.checkIfTemplateHasChangedAndNotifySubscribers(); + }, 'core/edit-site' ); + } + + public add( observer: TemplateChangeDetectorObserver ): void { + this.observers.push( observer ); + } + + /** + * Trigger an update in each subscriber. + */ + public notify(): void { + for ( const observer of this.observers ) { + observer.run( this ); + } + } + + public getPreviousTemplateId() { + return this.previousTemplateId; + } + + public getCurrentTemplateId() { + return this.currentTemplateId; + } + + public getIsPostOrPage() { + return this.isPostOrPage; + } + + /** + * Parses the template ID. + * + * This method takes a template or a post ID and returns it parsed in the expected format. + * + * @param {string | number | undefined} templateId - The template ID to parse. + * @return {string | undefined} The parsed template ID. + */ + private parseTemplateId( + templateId: string | number | undefined + ): string | undefined { + if ( isNumber( templateId ) ) { + return String( templateId ); + } + return templateId?.split( '//' )[ 1 ]; + } + + /** + * Checks if the current template or page has changed and notifies subscribers. + * + * If the current template ID has changed and is not undefined (which means that it is not a page, post or template), it notifies all subscribers. + */ + public checkIfTemplateHasChangedAndNotifySubscribers(): void { + this.previousTemplateId = this.currentTemplateId; + + const postOrPageId = select( 'core/editor' )?.getCurrentPostId< + string | number | undefined + >(); + + this.isPostOrPage = Boolean( postOrPageId ); + + const editedPostId = + postOrPageId || + select( 'core/edit-site' )?.getEditedPostId< + string | number | undefined + >(); + this.currentTemplateId = this.parseTemplateId( editedPostId ); + + const hasChangedTemplate = + this.previousTemplateId !== this.currentTemplateId; + const hasTemplateId = Boolean( this.currentTemplateId ); + + if ( ! hasChangedTemplate || ! hasTemplateId ) { + return; + } + + this.notify(); + } +} diff --git a/assets/js/atomic/utils/register-block-single-product-template.ts b/assets/js/atomic/utils/register-block-single-product-template.ts index 13431dd0ec4..87784a0011c 100644 --- a/assets/js/atomic/utils/register-block-single-product-template.ts +++ b/assets/js/atomic/utils/register-block-single-product-template.ts @@ -46,7 +46,7 @@ export const registerBlockSingleProductTemplate = ( { // With GB 16.3.0 the return type can be a number: https://github.com/WordPress/gutenberg/issues/53230 currentTemplateId = parseTemplateId( - store?.getEditedPostId() as string | number | undefined + store?.getEditedPostId< string | number | undefined >() ); const hasChangedTemplate = previousTemplateId !== currentTemplateId; const hasTemplateId = Boolean( currentTemplateId ); diff --git a/assets/js/blocks/product-gallery/index.tsx b/assets/js/blocks/product-gallery/index.tsx index edee1129ea8..468f2e59306 100644 --- a/assets/js/blocks/product-gallery/index.tsx +++ b/assets/js/blocks/product-gallery/index.tsx @@ -7,10 +7,8 @@ import { registerBlockType } from '@wordpress/blocks'; /** * Internal dependencies */ -import { Edit } from './edit'; -import { Save } from './save'; import metadata from './block.json'; -import icon from './icon'; +import { ProductGalleryBlockSettings } from './settings'; import './style.scss'; import './inner-blocks/product-gallery-large-image-next-previous'; import './inner-blocks/product-gallery-pager'; @@ -18,9 +16,5 @@ import './inner-blocks/product-gallery-thumbnails'; if ( isExperimentalBuild() ) { // @ts-expect-error: `metadata` currently does not have a type definition in WordPress core. - registerBlockType( metadata, { - icon, - edit: Edit, - save: Save, - } ); + registerBlockType( metadata, ProductGalleryBlockSettings ); } diff --git a/assets/js/blocks/product-gallery/settings.ts b/assets/js/blocks/product-gallery/settings.ts new file mode 100644 index 00000000000..55f81423231 --- /dev/null +++ b/assets/js/blocks/product-gallery/settings.ts @@ -0,0 +1,12 @@ +/** + * Internal dependencies + */ +import { Edit } from './edit'; +import { Save } from './save'; +import icon from './icon'; + +export const ProductGalleryBlockSettings = { + icon, + edit: Edit, + save: Save, +}; diff --git a/assets/js/index.js b/assets/js/index.js index cdb1e57a0be..30f6f3e0211 100644 --- a/assets/js/index.js +++ b/assets/js/index.js @@ -14,6 +14,7 @@ import '../css/style.scss'; import './filters/block-list-block'; import './filters/get-block-attributes'; import './base/components/notice-banner/style.scss'; +import './atomic/utils/blocks-registration-manager'; setCategories( [ ...getCategories().filter( diff --git a/package.json b/package.json index 19da9a40901..4e3b31408d8 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "./assets/js/blocks/product-gallery/inner-blocks/**/index.tsx", "./assets/js/templates/revert-button/index.tsx", "./assets/js/settings/blocks/index.ts", + "./assets/js/atomic/utils/blocks-registration-manager/index.ts", "./packages/**/*.{tsx,ts,js}" ], "repository": { diff --git a/tests/e2e/tests/product-gallery/product-gallery.block_theme.side_effects.spec.ts b/tests/e2e/tests/product-gallery/product-gallery.block_theme.side_effects.spec.ts index 422bf6e5d59..6ff1fc57973 100644 --- a/tests/e2e/tests/product-gallery/product-gallery.block_theme.side_effects.spec.ts +++ b/tests/e2e/tests/product-gallery/product-gallery.block_theme.side_effects.spec.ts @@ -11,6 +11,7 @@ import { ProductGalleryPage } from './product-gallery.page'; const blockData = { name: 'woocommerce/product-gallery', + title: 'Product Gallery', selectors: { frontend: {}, editor: { @@ -240,6 +241,54 @@ test.describe( `${ blockData.name }`, () => { } ); } ); + test.describe( 'block availability', () => { + test( 'should be available on the Single Product Template', async ( { + page, + editorUtils, + } ) => { + await editorUtils.openGlobalBlockInserter(); + await page.getByRole( 'tab', { name: 'Blocks' } ).click(); + const productGalleryBlockOption = page + .getByRole( 'listbox', { name: 'WooCommerce' } ) + .getByRole( 'option', { name: blockData.title } ); + + await expect( productGalleryBlockOption ).toBeVisible(); + } ); + + test( 'should be available on the Product Gallery template part', async ( { + admin, + editorUtils, + page, + } ) => { + await admin.visitSiteEditor( { + postId: `woocommerce/woocommerce//product-gallery`, + postType: 'wp_template_part', + } ); + await editorUtils.enterEditMode(); + await editorUtils.openGlobalBlockInserter(); + await page.getByRole( 'tab', { name: 'Blocks' } ).click(); + const productGalleryBlockOption = page + .getByRole( 'listbox', { name: 'WooCommerce' } ) + .getByRole( 'option', { name: blockData.title } ); + + await expect( productGalleryBlockOption ).toBeVisible(); + } ); + + test( 'should be hidden on the post editor', async ( { + admin, + page, + editorUtils, + } ) => { + await admin.createNewPost( { legacyCanvas: true } ); + await editorUtils.openGlobalBlockInserter(); + const productGalleryBlockOption = page + .getByRole( 'listbox', { name: 'WooCommerce' } ) + .getByRole( 'option', { name: blockData.title } ); + + await expect( productGalleryBlockOption ).toBeHidden(); + } ); + } ); + test( 'should show (square) cropped main product images when crop option is enabled', async ( { page, editorUtils,