diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-query/constants.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-query/constants.ts index ec91fd2f4f1..8d821a76921 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-query/constants.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-query/constants.ts @@ -13,6 +13,8 @@ import { VARIATION_NAME as PRODUCT_TITLE_ID } from './variations/elements/produc import { VARIATION_NAME as PRODUCT_TEMPLATE_ID } from './variations/elements/product-template'; import { ImageSizing } from '../../atomic/blocks/product-elements/image/types'; +export const REPLACE_PRODUCTS_WITH_PRODUCT_COLLECTION = false; + export const EDIT_ATTRIBUTES_URL = '/wp-admin/edit.php?post_type=product&page=product_attributes'; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-query/inspector-controls.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-query/inspector-controls.tsx index 1099a6015b5..582a994d3ee 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-query/inspector-controls.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-query/inspector-controls.tsx @@ -4,11 +4,12 @@ import type { ElementType } from 'react'; import { __ } from '@wordpress/i18n'; import { InspectorControls } from '@wordpress/block-editor'; -import { useSelect } from '@wordpress/data'; +import { useSelect, subscribe } from '@wordpress/data'; import { addFilter } from '@wordpress/hooks'; import { ProductQueryFeedbackPrompt } from '@woocommerce/editor-components/feedback-prompt'; import { EditorBlock } from '@woocommerce/types'; import { usePrevious } from '@woocommerce/base-hooks'; +import { isWpVersion } from '@woocommerce/settings'; import { FormTokenField, ToggleControl, @@ -37,10 +38,12 @@ import { QUERY_DEFAULT_ATTRIBUTES, QUERY_LOOP_ID, STOCK_STATUS_OPTIONS, + REPLACE_PRODUCTS_WITH_PRODUCT_COLLECTION, } from './constants'; import { AttributesFilter } from './inspector-controls/attributes-filter'; import { PopularPresets } from './inspector-controls/popular-presets'; import { ProductSelector } from './inspector-controls/product-selector'; +import { replaceProductsWithProductCollection } from '../shared/scripts'; import './editor.scss'; @@ -266,3 +269,20 @@ export const withProductQueryControls = }; addFilter( 'editor.BlockEdit', QUERY_LOOP_ID, withProductQueryControls ); + +if ( isWpVersion( '6.1', '>=' ) ) { + let unsubscribe: ( () => void ) | undefined; + if ( REPLACE_PRODUCTS_WITH_PRODUCT_COLLECTION && ! unsubscribe ) { + // console.info( 'Subscribed to allow Products block migration' ); + unsubscribe = subscribe( () => { + replaceProductsWithProductCollection( () => { + // console.info( + // 'Unsubscribed and disallow further Products block migration' + // ); + if ( unsubscribe ) { + unsubscribe(); + } + } ); + }, 'core/block-editor' ); + } +} diff --git a/plugins/woocommerce-blocks/assets/js/blocks/shared/scripts/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/shared/scripts/index.tsx new file mode 100644 index 00000000000..07daa016eb2 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/shared/scripts/index.tsx @@ -0,0 +1 @@ +export * from './migration-from-products-to-product-collection'; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/shared/scripts/migration-from-products-to-product-collection.tsx b/plugins/woocommerce-blocks/assets/js/blocks/shared/scripts/migration-from-products-to-product-collection.tsx new file mode 100644 index 00000000000..2b6f7a17162 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/shared/scripts/migration-from-products-to-product-collection.tsx @@ -0,0 +1,220 @@ +/** + * External dependencies + */ +import { createBlock, BlockInstance } from '@wordpress/blocks'; +import { select, dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { + getProductsBlockClientIds, + checkIfBlockCanBeInserted, + postTemplateHasSupportForGridView, + type TransformBlock, + type IsBlockType, + type ProductGridLayout, + type ProductGridLayoutTypes, + type PostTemplateLayout, + type PostTemplateLayoutTypes, +} from './migration-utils'; + +const mapAttributes = ( attributes: Record< string, unknown > ) => { + const { query, namespace, ...restAttributes } = attributes; + const { + __woocommerceAttributes, + __woocommerceStockStatus, + __woocommerceOnSale, + include, + ...restQuery + } = query; + + return { + ...restAttributes, + query: { + woocommerceAttributes: __woocommerceAttributes, + woocommerceStockStatus: __woocommerceStockStatus, + woocommerceOnSale: __woocommerceOnSale, + woocommerceHandPickedProducts: include, + taxQuery: {}, + parents: [], + isProductCollectionBlock: true, + ...restQuery, + }, + displayUpgradeNotice: true, + }; +}; + +const isPostTemplate: IsBlockType = ( { name, attributes } ) => + name === 'core/post-template' && + attributes.__woocommerceNamespace === + 'woocommerce/product-query/product-template'; + +const isPostTitle: IsBlockType = ( { name, attributes } ) => + name === 'core/post-title' && + attributes.__woocommerceNamespace === + 'woocommerce/product-query/product-title'; + +const isPostSummary: IsBlockType = ( { name, attributes } ) => + name === 'core/post-excerpt' && + attributes.__woocommerceNamespace === + 'woocommerce/product-query/product-summary'; + +const transformPostTemplate: TransformBlock = ( block, innerBlocks ) => { + const { __woocommerceNamespace, className, layout, ...restAttrributes } = + block.attributes; + + return createBlock( + 'woocommerce/product-template', + restAttrributes, + innerBlocks + ); +}; + +const transformPostTitle: TransformBlock = ( block, innerBlocks ) => { + const { __woocommerceNamespace, ...restAttrributes } = block.attributes; + return createBlock( + 'core/post-title', + { + __woocommerceNamespace: + 'woocommerce/product-collection/product-title', + ...restAttrributes, + }, + innerBlocks + ); +}; + +const transformPostSummary: TransformBlock = ( block, innerBlocks ) => { + const { __woocommerceNamespace, ...restAttrributes } = block.attributes; + return createBlock( + 'core/post-excerpt', + { + __woocommerceNamespace: + 'woocommerce/product-collection/product-summary', + ...restAttrributes, + }, + innerBlocks + ); +}; + +const mapLayoutType = ( + type: PostTemplateLayoutTypes +): ProductGridLayoutTypes => { + if ( type === 'grid' ) { + return 'flex'; + } + if ( type === 'default' ) { + return 'list'; + } + return 'flex'; +}; + +const mapLayoutPropertiesFromPostTemplateToProductCollection = ( + layout: PostTemplateLayout +): ProductGridLayout => { + const { type, columnCount } = layout; + + return { + type: mapLayoutType( type ), + columns: columnCount, + }; +}; + +const getLayoutAttribute = ( + attributes, + innerBlocks: BlockInstance[] +): ProductGridLayout => { + // Starting from GB 16, it's not Query Loop that keeps the layout, but the Post Template block. + // We need to account for that and in that case, move the layout properties + // from Post Template to Product Collection. + const postTemplate = innerBlocks.find( isPostTemplate ); + const { layout: postTemplateLayout } = postTemplate?.attributes || {}; + return postTemplateHasSupportForGridView + ? mapLayoutPropertiesFromPostTemplateToProductCollection( + postTemplateLayout + ) + : attributes.displayLayout; +}; + +const mapInnerBlocks = ( innerBlocks: BlockInstance[] ): BlockInstance[] => { + const mappedInnerBlocks = innerBlocks.map( ( innerBlock ) => { + const { name, attributes } = innerBlock; + + const mappedInnerInnerBlocks = mapInnerBlocks( innerBlock.innerBlocks ); + + if ( isPostTemplate( innerBlock ) ) { + return transformPostTemplate( innerBlock, mappedInnerInnerBlocks ); + } + + if ( isPostTitle( innerBlock ) ) { + return transformPostTitle( innerBlock, mappedInnerInnerBlocks ); + } + + if ( isPostSummary( innerBlock ) ) { + return transformPostSummary( innerBlock, mappedInnerInnerBlocks ); + } + return createBlock( name, attributes, mappedInnerInnerBlocks ); + } ); + + return mappedInnerBlocks; +}; + +const replaceProductsBlock = ( clientId: string ) => { + const productsBlock = select( 'core/block-editor' ).getBlock( clientId ); + const canBeInserted = checkIfBlockCanBeInserted( + clientId, + 'woocommerce/product-collection' + ); + + if ( productsBlock && canBeInserted ) { + const { attributes = {}, innerBlocks = [] } = productsBlock; + const displayLayout = getLayoutAttribute( attributes, innerBlocks ); + const adjustedAttributes = mapAttributes( { + ...attributes, + displayLayout, + } ); + const adjustedInnerBlocks = mapInnerBlocks( innerBlocks ); + + const productCollectionBlock = createBlock( + 'woocommerce/product-collection', + adjustedAttributes, + adjustedInnerBlocks + ); + dispatch( 'core/block-editor' ).replaceBlock( + clientId, + productCollectionBlock + ); + return true; + } + return false; +}; + +const replaceProductsBlocks = ( productsBlockClientIds: string[] ) => { + const results = productsBlockClientIds.map( replaceProductsBlock ); + return !! results.length && results.every( ( result ) => !! result ); +}; + +export const replaceProductsWithProductCollection = ( + unsubscribe: () => void +) => { + const queryBlocksCount = + select( 'core/block-editor' ).getGlobalBlockCount( 'core/query' ); + if ( queryBlocksCount === 0 ) { + return; + } + + const blocks = select( 'core/block-editor' ).getBlocks(); + const productsBlockClientIds = getProductsBlockClientIds( blocks ); + const productsBlocksCount = productsBlockClientIds.length; + + if ( productsBlocksCount === 0 ) { + return; + } + + const replaced = replaceProductsBlocks( productsBlockClientIds ); + + if ( replaced ) { + // @todo: unsubscribe on user reverting migration + unsubscribe(); + } +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/shared/scripts/migration-utils.tsx b/plugins/woocommerce-blocks/assets/js/blocks/shared/scripts/migration-utils.tsx new file mode 100644 index 00000000000..adcac37eb17 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/shared/scripts/migration-utils.tsx @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import { getSettingWithCoercion } from '@woocommerce/settings'; +import { type BlockInstance } from '@wordpress/blocks'; +import { select } from '@wordpress/data'; +import { isBoolean } from '@woocommerce/types'; + +type GetBlocksClientIds = ( blocks: BlockInstance[] ) => string[]; +export type IsBlockType = ( block: BlockInstance ) => boolean; +export type TransformBlock = ( + block: BlockInstance, + innerBlock: BlockInstance[] +) => BlockInstance; +export type ProductGridLayoutTypes = 'flex' | 'list'; +export type PostTemplateLayoutTypes = 'grid' | 'default'; + +export type ProductGridLayout = { + type: ProductGridLayoutTypes; + columns: number; +}; + +export type PostTemplateLayout = { + type: PostTemplateLayoutTypes; + columnCount: number; +}; + +const isProductsBlock: IsBlockType = ( block ) => + block.name === 'core/query' && + block.attributes.namespace === 'woocommerce/product-query'; + +const isProductCollectionBlock: IsBlockType = ( block ) => + block.name === 'woocommerce/product-collection'; + +const getBlockClientIdsByPredicate = ( + blocks: BlockInstance[], + predicate: ( block: BlockInstance ) => boolean +): string[] => { + let clientIds: string[] = []; + blocks.forEach( ( block ) => { + if ( predicate( block ) ) { + clientIds = [ ...clientIds, block.clientId ]; + } + clientIds = [ + ...clientIds, + ...getBlockClientIdsByPredicate( block.innerBlocks, predicate ), + ]; + } ); + return clientIds; +}; + +const getProductsBlockClientIds: GetBlocksClientIds = ( blocks ) => + getBlockClientIdsByPredicate( blocks, isProductsBlock ); + +const getProductCollectionBlockClientIds: GetBlocksClientIds = ( blocks ) => + getBlockClientIdsByPredicate( blocks, isProductCollectionBlock ); + +const checkIfBlockCanBeInserted = ( + clientId: string, + blockToBeInserted: string +) => { + // We need to duplicate checks that are happening within replaceBlocks method + // as replacement is initially blocked and there's no information returned + // that would determine if replacement happened or not. + // https://github.com/WordPress/gutenberg/issues/46740 + const rootClientId = + select( 'core/block-editor' ).getBlockRootClientId( clientId ) || + undefined; + return select( 'core/block-editor' ).canInsertBlockType( + blockToBeInserted, + rootClientId + ); +}; + +const postTemplateHasSupportForGridView = getSettingWithCoercion( + 'post_template_has_support_for_grid_view', + false, + isBoolean +); + +export { + getProductsBlockClientIds, + getProductCollectionBlockClientIds, + checkIfBlockCanBeInserted, + postTemplateHasSupportForGridView, +};