diff --git a/docs/reference-guides/block-api/block-transforms.md b/docs/reference-guides/block-api/block-transforms.md
index 59176ef2a8cc4b..33d418cc68dd5b 100644
--- a/docs/reference-guides/block-api/block-transforms.md
+++ b/docs/reference-guides/block-api/block-transforms.md
@@ -42,6 +42,7 @@ A transformation of type `block` is an object that takes the following parameter
- **type** _(string)_: the value `block`.
- **blocks** _(array)_: a list of known block types. It also accepts the wildcard value (`"*"`), meaning that the transform is available to _all_ block types (eg: all blocks can transform into `core/group`).
- **transform** _(function)_: a callback that receives the attributes and inner blocks of the block being processed. It should return a block object or an array of block objects.
+- **variationName** _(string, optional)_: the name of the target block variation when the transform creates a variation of the transformed block type. This lets the transform UI show the variation title and icon instead of the base block type.
- **isMatch** _(function, optional)_: a callback that receives the block attributes as the first argument and the block object as the second argument and should return a boolean. Returning `false` from this function will prevent the transform from being available and displayed as an option to the user.
- **isMultiBlock** _(boolean, optional)_: whether the transformation can be applied when multiple blocks are selected. If true, the `transform` function's first parameter will be an array containing each selected block's attributes, and the second an array of each selected block's inner blocks. False by default.
- **priority** _(number, optional)_: controls the priority with which a transformation is applied, where a lower value will take precedence over higher values. This behaves much like a [WordPress hook](https://developer.wordpress.org/reference/#Hook_to_WordPress). Like hooks, the default priority is `10` when not otherwise set.
diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md
index 40aae1223254a8..bd29ed3efa447a 100644
--- a/docs/reference-guides/data/data-core-block-editor.md
+++ b/docs/reference-guides/data/data-core-block-editor.md
@@ -470,6 +470,7 @@ _Properties_
- _id_ `string`: Unique identifier for the item.
- _name_ `string`: The type of block to create.
+- _variationName_ `?string`: The target block variation name.
- _title_ `string`: Title of the item, as it appears in the inserter.
- _icon_ `string`: Dashicon for the item, as it appears in the inserter.
- _isDisabled_ `boolean`: Whether or not the user should be prevented from inserting this item.
diff --git a/packages/block-editor/src/components/block-switcher/block-transformations-menu.js b/packages/block-editor/src/components/block-switcher/block-transformations-menu.js
index f0f2e99a79d76b..cb9f4cb7db87ef 100644
--- a/packages/block-editor/src/components/block-switcher/block-transformations-menu.js
+++ b/packages/block-editor/src/components/block-switcher/block-transformations-menu.js
@@ -88,8 +88,7 @@ const BlockTransformationsMenu = ( {
onSelectVariation,
blocks,
} ) => {
- const [ hoveredTransformItemName, setHoveredTransformItemName ] =
- useState();
+ const [ hoveredTransformItem, setHoveredTransformItem ] = useState();
const { priorityTextTransformations, restTransformations } =
useGroupedTransforms( possibleBlockTransformations );
@@ -101,17 +100,18 @@ const BlockTransformationsMenu = ( {
);
return (
<>
- { hoveredTransformItemName && (
+ { hoveredTransformItem && (
) }
@@ -126,12 +126,10 @@ const BlockTransformationsMenu = ( {
) }
{ priorityTextTransformations.map( ( item ) => (
) ) }
{ ! hasBothContentTransformations && restTransformItems }
@@ -148,14 +146,14 @@ const BlockTransformationsMenu = ( {
function RestTransformationItems( {
restTransformations,
onSelect,
- setHoveredTransformItemName,
+ setHoveredTransformItem,
} ) {
return restTransformations.map( ( item ) => (
) );
}
@@ -163,7 +161,7 @@ function RestTransformationItems( {
function BlockTransformationItem( {
item,
onSelect,
- setHoveredTransformItemName,
+ setHoveredTransformItem,
} ) {
const { name, icon, title, isDisabled } = item;
return (
@@ -171,13 +169,13 @@ function BlockTransformationItem( {
className={ getBlockMenuDefaultClassName( name ) }
onClick={ ( event ) => {
event.preventDefault();
- onSelect( name );
+ onSelect( name, item.variationName );
} }
disabled={ isDisabled }
- onMouseLeave={ () => setHoveredTransformItemName( null ) }
- onMouseEnter={ () => setHoveredTransformItemName( name ) }
- onFocus={ () => setHoveredTransformItemName( name ) }
- onBlur={ () => setHoveredTransformItemName( null ) }
+ onMouseLeave={ () => setHoveredTransformItem( null ) }
+ onMouseEnter={ () => setHoveredTransformItem( item ) }
+ onFocus={ () => setHoveredTransformItem( item ) }
+ onBlur={ () => setHoveredTransformItem( null ) }
>
{ title }
diff --git a/packages/block-editor/src/components/block-switcher/block-transformations-menu.native.js b/packages/block-editor/src/components/block-switcher/block-transformations-menu.native.js
index 4b95af2c59d523..133d50cb9df295 100644
--- a/packages/block-editor/src/components/block-switcher/block-transformations-menu.native.js
+++ b/packages/block-editor/src/components/block-switcher/block-transformations-menu.native.js
@@ -56,19 +56,28 @@ const BlockTransformationsMenu = ( {
anchorNodeRef ? findNodeHandle( anchorNodeRef ) : undefined;
function onPickerSelect( value ) {
+ const selectedItem = possibleTransformations.find(
+ ( item ) => item.id === value
+ );
+ if ( ! selectedItem ) {
+ return;
+ }
replaceBlocks(
selectedBlockClientId,
- switchToBlockType( selectedBlock, value )
+ switchToBlockType(
+ selectedBlock,
+ selectedItem.name,
+ selectedItem.variationName
+ )
);
-
- const selectedItem = pickerOptions().find(
+ const selectedOption = pickerOptions().find(
( item ) => item.value === value
);
const successNotice = sprintf(
/* translators: 1: From block title, e.g. Paragraph. 2: To block title, e.g. Header. */
__( '%1$s transformed to %2$s' ),
blockTitle,
- selectedItem.label
+ selectedOption?.label || selectedItem.title
);
createSuccessNotice( successNotice );
}
diff --git a/packages/block-editor/src/components/block-switcher/index.js b/packages/block-editor/src/components/block-switcher/index.js
index cf3ae9beb1ecec..48fa74aa96482f 100644
--- a/packages/block-editor/src/components/block-switcher/index.js
+++ b/packages/block-editor/src/components/block-switcher/index.js
@@ -87,8 +87,8 @@ function BlockSwitcherDropdownMenuContents( { onClose, clientIds } ) {
}
}
// Simple block transformation based on the `Block Transforms` API.
- function onBlockTransform( name ) {
- const newBlocks = switchToBlockType( blocks, name );
+ function onBlockTransform( name, variationName ) {
+ const newBlocks = switchToBlockType( blocks, name, variationName );
replaceBlocks( clientIds, newBlocks );
selectForMultipleBlocks( newBlocks );
}
@@ -166,8 +166,8 @@ function BlockSwitcherDropdownMenuContents( { onClose, clientIds } ) {
blockVariationTransformations
}
blocks={ blocks }
- onSelect={ ( name ) => {
- onBlockTransform( name );
+ onSelect={ ( name, variationName ) => {
+ onBlockTransform( name, variationName );
onClose();
} }
onSelectVariation={ ( name ) => {
diff --git a/packages/block-editor/src/components/use-block-commands/index.js b/packages/block-editor/src/components/use-block-commands/index.js
index e0f8f134b23a72..9c6fc05f473980 100644
--- a/packages/block-editor/src/components/use-block-commands/index.js
+++ b/packages/block-editor/src/components/use-block-commands/index.js
@@ -93,8 +93,8 @@ const getTransformCommands = () =>
}
// Simple block transformation based on the `Block Transforms` API.
- function onBlockTransform( name ) {
- const newBlocks = switchToBlockType( blocks, name );
+ function onBlockTransform( name, variationName ) {
+ const newBlocks = switchToBlockType( blocks, name, variationName );
replaceBlocks( clientIds, newBlocks );
selectForMultipleBlocks( newBlocks );
}
@@ -117,7 +117,7 @@ const getTransformCommands = () =>
const commands = possibleBlockTransformations.map(
( transformation ) => {
- const { name, title, icon } = transformation;
+ const { id, name, title, icon, variationName } = transformation;
/*
* Command menu uses Icon from @wordpress/icons, which expects a ReactElement
* (cloneElement). Normalize to blockDefaultIcon to avoid crash. See #55668 / PR #55676.
@@ -132,13 +132,13 @@ const getTransformCommands = () =>
return {
name:
'core/block-editor/transform-to-' +
- name.replace( '/', '-' ),
+ ( id || name ).replace( /\//g, '-' ),
/* translators: %s: Block or block variation name. */
label: sprintf( __( 'Transform to %s' ), title ),
icon: blockIcon?.src,
category: 'command',
callback: ( { close } ) => {
- onBlockTransform( name );
+ onBlockTransform( name, variationName );
close();
},
};
diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js
index 740fdd885966d6..b634fb16f670ac 100644
--- a/packages/block-editor/src/store/selectors.js
+++ b/packages/block-editor/src/store/selectors.js
@@ -2294,6 +2294,19 @@ const buildBlockTypeItem =
};
};
+const buildBlockVariationItem = ( state, item ) => ( variation ) => {
+ const variationId = `${ item.id }/${ variation.name }`;
+ const { time, count = 0 } = getInsertUsage( state, variationId ) || {};
+ return {
+ ...item,
+ id: variationId,
+ icon: variation.icon || item.icon,
+ title: variation.title || item.title,
+ frecency: calculateFrecency( time, count ),
+ variationName: variation.name,
+ };
+};
+
/**
* Determines the items that appear in the inserter. Includes both static
* items (e.g. a regular block type) and dynamic items (e.g. a reusable block).
@@ -2474,20 +2487,21 @@ export const getInserterItems = createRegistrySelector( ( select ) =>
*
* Items are returned ordered descendingly by their 'frecency'.
*
- * @param {Object} state Editor state.
- * @param {Object|Object[]} blocks Block object or array objects.
- * @param {?string} rootClientId Optional root client ID of block list.
+ * @param {Object} state Editor state.
+ * @param {Object|Object[]} blocks Block object or array objects.
+ * @param {?string} rootClientId Optional root client ID of block list.
*
* @return {WPEditorTransformItem[]} Items that appear in inserter.
*
* @typedef {Object} WPEditorTransformItem
- * @property {string} id Unique identifier for the item.
- * @property {string} name The type of block to create.
- * @property {string} title Title of the item, as it appears in the inserter.
- * @property {string} icon Dashicon for the item, as it appears in the inserter.
- * @property {boolean} isDisabled Whether or not the user should be prevented from inserting
- * this item.
- * @property {number} frecency Heuristic that combines frequency and recency.
+ * @property {string} id Unique identifier for the item.
+ * @property {string} name The type of block to create.
+ * @property {?string} variationName The target block variation name.
+ * @property {string} title Title of the item, as it appears in the inserter.
+ * @property {string} icon Dashicon for the item, as it appears in the inserter.
+ * @property {boolean} isDisabled Whether or not the user should be prevented from inserting
+ * this item.
+ * @property {number} frecency Heuristic that combines frequency and recency.
*/
export const getBlockTransformItems = createRegistrySelector( ( select ) =>
createSelector(
@@ -2517,14 +2531,37 @@ export const getBlockTransformItems = createRegistrySelector( ( select ) =>
const possibleTransforms = getPossibleBlockTransformations(
normalizedBlocks
).reduce( ( accumulator, block ) => {
- if ( itemsByName[ block?.name ] ) {
- accumulator.push( itemsByName[ block.name ] );
+ const item = itemsByName[ block?.name ];
+
+ if ( ! item ) {
+ return accumulator;
+ }
+
+ const { variationName } = block;
+
+ if ( ! variationName ) {
+ accumulator.push( item );
+ return accumulator;
}
+
+ const variation = getBlockVariations(
+ item.name,
+ 'transform'
+ )?.find( ( { name } ) => name === variationName );
+
+ if ( ! variation ) {
+ accumulator.push( item );
+ return accumulator;
+ }
+
+ accumulator.push(
+ buildBlockVariationItem( state, item )( variation )
+ );
return accumulator;
}, [] );
return orderBy(
possibleTransforms,
- ( block ) => itemsByName[ block.name ].frecency,
+ ( block ) => block.frecency,
'desc'
);
},
diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js
index 37dd9255037ae0..64c7f554246565 100644
--- a/packages/block-editor/src/store/test/selectors.js
+++ b/packages/block-editor/src/store/test/selectors.js
@@ -4031,6 +4031,82 @@ describe( 'selectors', () => {
} )
);
} );
+
+ it( 'should use variation metadata for transformation items', () => {
+ registerBlockType( 'core/variation-transform-source', {
+ apiVersion: 3,
+ category: 'text',
+ title: 'Variation Transform Source',
+ edit: () => {},
+ save: () => {},
+ transforms: {
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'core/variation-transform-target' ],
+ variationName: 'grid',
+ transform: () => {},
+ },
+ ],
+ },
+ } );
+ registerBlockType( 'core/variation-transform-target', {
+ apiVersion: 3,
+ category: 'design',
+ title: 'Group',
+ icon: 'group',
+ edit: () => {},
+ save: () => {},
+ variations: [
+ {
+ name: 'grid',
+ title: 'Grid',
+ icon: 'grid',
+ attributes: { layout: { type: 'grid' } },
+ scope: [ 'transform' ],
+ },
+ ],
+ } );
+
+ const state = {
+ blocks: {
+ byClientId: new Map(),
+ attributes: new Map(),
+ order: new Map(),
+ parents: new Map(),
+ cache: {},
+ blockEditingModes: new Map(),
+ },
+ preferences: {
+ insertUsage: {
+ 'core/variation-transform-target/grid': {
+ count: 10,
+ time: 1000,
+ },
+ },
+ },
+ blockListSettings: new Map(),
+ settings: {},
+ };
+ const blocks = [ { name: 'core/variation-transform-source' } ];
+
+ try {
+ const items = getBlockTransformItems( state, blocks );
+
+ expect( items ).toHaveLength( 1 );
+ expect( items[ 0 ] ).toMatchObject( {
+ id: 'core/variation-transform-target/grid',
+ name: 'core/variation-transform-target',
+ variationName: 'grid',
+ title: 'Grid',
+ icon: 'grid',
+ frecency: 2.5,
+ } );
+ } finally {
+ unregisterBlockType( 'core/variation-transform-source' );
+ unregisterBlockType( 'core/variation-transform-target' );
+ }
+ } );
} );
describe( 'isValidTemplate', () => {
diff --git a/packages/block-library/src/columns/test/transforms.js b/packages/block-library/src/columns/test/transforms.js
new file mode 100644
index 00000000000000..8652b06b9e8916
--- /dev/null
+++ b/packages/block-library/src/columns/test/transforms.js
@@ -0,0 +1,164 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ createBlock,
+ getBlockTypes,
+ registerBlockType,
+ switchToBlockType,
+ unregisterBlockType,
+} from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import {
+ metadata as columnsMetadata,
+ settings as columnsSettings,
+} from '../index';
+import {
+ metadata as columnMetadata,
+ settings as columnSettings,
+} from '../../column';
+import {
+ metadata as groupMetadata,
+ settings as groupSettings,
+} from '../../group';
+
+describe( 'transforms', () => {
+ beforeAll( () => {
+ registerBlockType( columnsMetadata, columnsSettings );
+ registerBlockType( columnMetadata, columnSettings );
+ registerBlockType( groupMetadata, groupSettings );
+ registerBlockType( 'core/paragraph', {
+ apiVersion: 3,
+ attributes: {
+ content: {
+ type: 'string',
+ },
+ },
+ save: () => {},
+ category: 'text',
+ title: 'Paragraph',
+ } );
+ } );
+
+ afterAll( () => {
+ getBlockTypes().forEach( ( block ) => {
+ unregisterBlockType( block.name );
+ } );
+ } );
+
+ it( 'transforms Columns to the Grid variation of Group', () => {
+ const block = createBlock(
+ 'core/columns',
+ {
+ align: 'wide',
+ isStackedOnMobile: false,
+ verticalAlignment: 'center',
+ },
+ [
+ createBlock( 'core/column', {}, [
+ createBlock( 'core/paragraph', { content: 'One' } ),
+ ] ),
+ createBlock( 'core/column', {}, [
+ createBlock( 'core/paragraph', { content: 'Two' } ),
+ createBlock( 'core/paragraph', { content: 'Three' } ),
+ ] ),
+ ]
+ );
+
+ const transformedBlocks = switchToBlockType(
+ block,
+ 'core/group',
+ 'group-grid'
+ );
+
+ expect( transformedBlocks[ 0 ] ).toMatchObject( {
+ name: 'core/group',
+ attributes: {
+ align: 'wide',
+ layout: { type: 'grid', columnCount: 2 },
+ },
+ } );
+ expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty(
+ 'isStackedOnMobile'
+ );
+ expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty(
+ 'verticalAlignment'
+ );
+ expect( transformedBlocks[ 0 ].innerBlocks ).toHaveLength( 2 );
+ expect( transformedBlocks[ 0 ].innerBlocks[ 0 ] ).toMatchObject( {
+ name: 'core/paragraph',
+ attributes: { content: 'One' },
+ } );
+ expect( transformedBlocks[ 0 ].innerBlocks[ 1 ] ).toMatchObject( {
+ name: 'core/group',
+ attributes: { layout: { type: 'constrained' } },
+ innerBlocks: [
+ expect.objectContaining( {
+ name: 'core/paragraph',
+ attributes: { content: 'Two' },
+ } ),
+ expect.objectContaining( {
+ name: 'core/paragraph',
+ attributes: { content: 'Three' },
+ } ),
+ ],
+ } );
+ } );
+
+ it( 'transforms Grid variation of Group to Columns using the explicit grid column count', () => {
+ const block = createBlock(
+ 'core/group',
+ {
+ align: 'wide',
+ layout: { type: 'grid', columnCount: 3 },
+ },
+ [
+ createBlock( 'core/paragraph', { content: 'One' } ),
+ createBlock( 'core/paragraph', { content: 'Two' } ),
+ ]
+ );
+
+ const transformedBlocks = switchToBlockType( block, 'core/columns' );
+
+ expect( transformedBlocks[ 0 ] ).toMatchObject( {
+ name: 'core/columns',
+ attributes: {
+ align: 'wide',
+ },
+ } );
+ expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty(
+ 'layout'
+ );
+ expect( transformedBlocks[ 0 ].innerBlocks ).toHaveLength( 3 );
+ expect( transformedBlocks[ 0 ].innerBlocks ).toEqual( [
+ expect.objectContaining( {
+ name: 'core/column',
+ attributes: { width: '33.33%' },
+ innerBlocks: [
+ expect.objectContaining( {
+ name: 'core/paragraph',
+ attributes: { content: 'One' },
+ } ),
+ ],
+ } ),
+ expect.objectContaining( {
+ name: 'core/column',
+ attributes: { width: '33.33%' },
+ innerBlocks: [
+ expect.objectContaining( {
+ name: 'core/paragraph',
+ attributes: { content: 'Two' },
+ } ),
+ ],
+ } ),
+ expect.objectContaining( {
+ name: 'core/column',
+ attributes: { width: '33.33%' },
+ innerBlocks: [],
+ } ),
+ ] );
+ } );
+} );
diff --git a/packages/block-library/src/columns/transforms.js b/packages/block-library/src/columns/transforms.js
index 3a15892431f251..a1b8db0491aeea 100644
--- a/packages/block-library/src/columns/transforms.js
+++ b/packages/block-library/src/columns/transforms.js
@@ -8,8 +8,59 @@ import {
const MAXIMUM_SELECTED_BLOCKS = 6;
+const getColumnBlocksFromGrid = ( innerBlocks, columnCount ) => {
+ const columnWidth = +( 100 / columnCount ).toFixed( 2 );
+ const innerBlocksTemplate = Array.from(
+ { length: columnCount },
+ ( _, columnIndex ) => [
+ 'core/column',
+ { width: `${ columnWidth }%` },
+ innerBlocks.filter(
+ ( _innerBlock, blockIndex ) =>
+ blockIndex % columnCount === columnIndex
+ ),
+ ]
+ );
+
+ return createBlocksFromInnerBlocksTemplate( innerBlocksTemplate );
+};
+
+const getGridInnerBlocks = ( innerBlocks ) =>
+ innerBlocks.flatMap( ( column ) => {
+ const columnInnerBlocks = column.innerBlocks || [];
+ if ( columnInnerBlocks.length > 1 ) {
+ return [
+ createBlock(
+ 'core/group',
+ { layout: { type: 'constrained' } },
+ columnInnerBlocks
+ ),
+ ];
+ }
+ return columnInnerBlocks;
+ } );
+
const transforms = {
from: [
+ {
+ type: 'block',
+ blocks: [ 'core/group' ],
+ priority: 1,
+ transform: ( attributes, innerBlocks ) => {
+ const { layout, ...rest } = attributes;
+ const { columnCount } = layout;
+
+ return createBlock(
+ 'core/columns',
+ rest,
+ getColumnBlocksFromGrid( innerBlocks, columnCount )
+ );
+ },
+ isMatch: ( { layout } ) =>
+ layout?.type === 'grid' &&
+ Number.isInteger( layout?.columnCount ) &&
+ layout.columnCount > 0,
+ },
{
type: 'block',
isMultiBlock: true,
@@ -105,6 +156,29 @@ const transforms = {
},
},
],
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'core/group' ],
+ variationName: 'group-grid',
+ transform: ( attributes, innerBlocks ) => {
+ const columnCount = innerBlocks.length;
+ return createBlock(
+ 'core/group',
+ {
+ ...attributes,
+ isStackedOnMobile: undefined,
+ verticalAlignment: undefined,
+ layout: {
+ type: 'grid',
+ ...( columnCount && { columnCount } ),
+ },
+ },
+ getGridInnerBlocks( innerBlocks )
+ );
+ },
+ },
+ ],
ungroup: ( attributes, innerBlocks ) =>
innerBlocks.flatMap( ( innerBlock ) => innerBlock.innerBlocks ),
};
diff --git a/packages/block-library/src/gallery/test/transforms.js b/packages/block-library/src/gallery/test/transforms.js
new file mode 100644
index 00000000000000..80563fbfe9efcf
--- /dev/null
+++ b/packages/block-library/src/gallery/test/transforms.js
@@ -0,0 +1,155 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ createBlock,
+ getBlockTypes,
+ registerBlockType,
+ switchToBlockType,
+ unregisterBlockType,
+} from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import {
+ metadata as galleryMetadata,
+ settings as gallerySettings,
+} from '../index';
+import {
+ metadata as groupMetadata,
+ settings as groupSettings,
+} from '../../group';
+
+describe( 'transforms', () => {
+ beforeAll( () => {
+ registerBlockType( galleryMetadata, gallerySettings );
+ registerBlockType( groupMetadata, groupSettings );
+ registerBlockType( 'core/image', {
+ apiVersion: 3,
+ attributes: {
+ url: {
+ type: 'string',
+ },
+ alt: {
+ type: 'string',
+ },
+ caption: {
+ type: 'rich-text',
+ },
+ id: {
+ type: 'number',
+ },
+ sizeSlug: {
+ type: 'string',
+ },
+ },
+ save: () => {},
+ category: 'media',
+ title: 'Image',
+ } );
+ } );
+
+ afterAll( () => {
+ getBlockTypes().forEach( ( block ) => {
+ unregisterBlockType( block.name );
+ } );
+ } );
+
+ it( 'transforms Gallery to the Grid variation of Group with images as direct children', () => {
+ const firstImage = createBlock( 'core/image', {
+ url: 'https://example.com/one.jpg',
+ alt: 'One',
+ id: 1,
+ sizeSlug: 'large',
+ } );
+ const secondImage = createBlock( 'core/image', {
+ url: 'https://example.com/two.jpg',
+ alt: 'Two',
+ id: 2,
+ sizeSlug: 'large',
+ } );
+ const block = createBlock(
+ 'core/gallery',
+ {
+ align: 'wide',
+ caption: 'Gallery caption',
+ columns: 2,
+ imageCrop: false,
+ },
+ [ firstImage, secondImage ]
+ );
+
+ const transformedBlocks = switchToBlockType(
+ block,
+ 'core/group',
+ 'group-grid'
+ );
+
+ expect( transformedBlocks[ 0 ] ).toMatchObject( {
+ name: 'core/group',
+ attributes: {
+ align: 'wide',
+ layout: { type: 'grid', columnCount: 2 },
+ },
+ } );
+ expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty(
+ 'caption'
+ );
+ expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty(
+ 'columns'
+ );
+ expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty(
+ 'imageCrop'
+ );
+ expect( transformedBlocks[ 0 ].innerBlocks ).toHaveLength( 2 );
+ expect( transformedBlocks[ 0 ].innerBlocks[ 0 ] ).toMatchObject( {
+ name: 'core/image',
+ attributes: {
+ url: 'https://example.com/one.jpg',
+ alt: 'One',
+ id: 1,
+ sizeSlug: 'large',
+ },
+ } );
+ expect( transformedBlocks[ 0 ].innerBlocks[ 1 ] ).toMatchObject( {
+ name: 'core/image',
+ attributes: {
+ url: 'https://example.com/two.jpg',
+ alt: 'Two',
+ id: 2,
+ sizeSlug: 'large',
+ },
+ } );
+ expect( transformedBlocks[ 0 ].innerBlocks[ 0 ].clientId ).not.toBe(
+ firstImage.clientId
+ );
+ expect( transformedBlocks[ 0 ].innerBlocks[ 1 ].clientId ).not.toBe(
+ secondImage.clientId
+ );
+ } );
+
+ it( 'transforms Gallery to the Grid variation of Group with the default gallery column count', () => {
+ const block = createBlock( 'core/gallery', {}, [
+ createBlock( 'core/image', {
+ url: 'https://example.com/one.jpg',
+ } ),
+ createBlock( 'core/image', {
+ url: 'https://example.com/two.jpg',
+ } ),
+ ] );
+
+ const transformedBlocks = switchToBlockType(
+ block,
+ 'core/group',
+ 'group-grid'
+ );
+
+ expect( transformedBlocks[ 0 ] ).toMatchObject( {
+ name: 'core/group',
+ attributes: {
+ layout: { type: 'grid', columnCount: 2 },
+ },
+ } );
+ } );
+} );
diff --git a/packages/block-library/src/gallery/transforms.js b/packages/block-library/src/gallery/transforms.js
index b57e74a868effe..f23af191574509 100644
--- a/packages/block-library/src/gallery/transforms.js
+++ b/packages/block-library/src/gallery/transforms.js
@@ -13,6 +13,7 @@ import {
LINK_DESTINATION_NONE,
LINK_DESTINATION_MEDIA,
} from './constants';
+import { defaultColumnsNumber } from './shared';
const parseShortcodeIds = ( ids ) => {
if ( ! ids ) {
@@ -22,6 +23,15 @@ const parseShortcodeIds = ( ids ) => {
return ids.split( ',' ).map( ( id ) => parseInt( id, 10 ) );
};
+const cloneInnerBlocks = ( innerBlocks ) =>
+ innerBlocks.map( ( innerBlock ) =>
+ createBlock(
+ innerBlock.name,
+ innerBlock.attributes,
+ cloneInnerBlocks( innerBlock.innerBlocks || [] )
+ )
+ );
+
/**
* Third party block plugins don't have an easy way to detect if the
* innerBlocks version of the Gallery is running when they run a
@@ -261,6 +271,43 @@ const transforms = {
return createBlock( 'core/image', { align } );
},
},
+ {
+ type: 'block',
+ blocks: [ 'core/group' ],
+ variationName: 'group-grid',
+ transform: ( attributes, innerBlocks ) => {
+ const {
+ allowResize,
+ aspectRatio,
+ caption,
+ columns,
+ fixedHeight,
+ ids,
+ imageCrop,
+ images,
+ linkTarget,
+ linkTo,
+ navigationButtonType,
+ randomOrder,
+ shortCodeTransforms,
+ sizeSlug,
+ ...rest
+ } = attributes;
+ return createBlock(
+ 'core/group',
+ {
+ ...rest,
+ layout: {
+ type: 'grid',
+ columnCount:
+ columns ??
+ defaultColumnsNumber( innerBlocks.length ),
+ },
+ },
+ cloneInnerBlocks( innerBlocks )
+ );
+ },
+ },
],
};
diff --git a/packages/blocks/README.md b/packages/blocks/README.md
index ee80df05735c50..d027785ebaef5f 100644
--- a/packages/blocks/README.md
+++ b/packages/blocks/README.md
@@ -280,7 +280,7 @@ _Parameters_
_Returns_
-- `BlockType[]`: Block types that the blocks argument can be transformed to.
+- `BlockTypeWithTransformMetadata[]`: Block types that the blocks argument can be transformed to.
### getSaveContent
@@ -826,6 +826,7 @@ _Parameters_
- _blocks_ `Block[] | Block`: Blocks array or block object.
- _name_ `string`: Block name.
+- _variationName_ `string`: Optional target block variation name.
_Returns_
diff --git a/packages/blocks/src/api/factory.ts b/packages/blocks/src/api/factory.ts
index 363e2a6e12cf28..3d79d693c33a38 100644
--- a/packages/blocks/src/api/factory.ts
+++ b/packages/blocks/src/api/factory.ts
@@ -23,6 +23,18 @@ import {
} from './utils';
import type { Block, BlockType, BlockTransform } from '../types';
+type BlockTypeWithTransformMetadata = BlockType & {
+ variationName?: string;
+};
+
+const getBlockTypeWithTransformMetadata = (
+ blockType: BlockType,
+ transform: BlockTransform
+): BlockTypeWithTransformMetadata =>
+ transform.variationName
+ ? { ...blockType, variationName: transform.variationName }
+ : blockType;
+
/**
* Returns a block object given its type and attributes.
*
@@ -260,24 +272,24 @@ const isPossibleTransformForSource = (
*/
const getBlockTypesForPossibleFromTransforms = (
blocks: Block[]
-): BlockType[] => {
+): BlockTypeWithTransformMetadata[] => {
if ( ! blocks.length ) {
return [];
}
const allBlockTypes = getBlockTypes();
- // filter all blocks to find those with a 'from' transform.
- const blockTypesWithPossibleFromTransforms = allBlockTypes.filter(
+ // Filter all blocks to find those with a 'from' transform.
+ const blockTypesWithPossibleFromTransforms = allBlockTypes.flatMap(
( blockType ) => {
const fromTransforms = getBlockTransforms( 'from', blockType.name );
- return !! findTransform( fromTransforms, ( transform ) => {
- return isPossibleTransformForSource(
- transform,
- 'from',
- blocks
+ return fromTransforms
+ .filter( ( transform ) =>
+ isPossibleTransformForSource( transform, 'from', blocks )
+ )
+ .map( ( transform ) =>
+ getBlockTypeWithTransformMetadata( blockType, transform )
);
- } );
}
);
@@ -294,7 +306,7 @@ const getBlockTypesForPossibleFromTransforms = (
*/
const getBlockTypesForPossibleToTransforms = (
blocks: Block[]
-): BlockType[] => {
+): BlockTypeWithTransformMetadata[] => {
if ( ! blocks.length ) {
return [];
}
@@ -312,16 +324,20 @@ const getBlockTypesForPossibleToTransforms = (
);
} );
- // Build a list of block names using the possible 'to' transforms.
- const blockNames = possibleTransforms
- .map( ( transformation ) => transformation.blocks )
- .flat();
-
// Map block names to block types.
- return blockNames
- .filter( ( name ): name is string => !! name )
- .map( getBlockType )
- .filter( ( bt ): bt is BlockType => !! bt );
+ return possibleTransforms
+ .flatMap( ( transformation ) => {
+ return ( transformation.blocks || [] ).map( ( name ) => {
+ const transformedBlockType = getBlockType( name );
+ return transformedBlockType
+ ? getBlockTypeWithTransformMetadata(
+ transformedBlockType,
+ transformation
+ )
+ : undefined;
+ } );
+ } )
+ .filter( ( bt ): bt is BlockTypeWithTransformMetadata => !! bt );
};
/**
@@ -363,7 +379,7 @@ export const isContainerGroupBlock = ( name: string ): boolean =>
*/
export function getPossibleBlockTransformations(
blocks: Block[]
-): BlockType[] {
+): BlockTypeWithTransformMetadata[] {
if ( ! blocks.length ) {
return [];
}
@@ -373,12 +389,24 @@ export function getPossibleBlockTransformations(
const blockTypesForToTransforms =
getBlockTypesForPossibleToTransforms( blocks );
- return [
- ...new Set( [
- ...blockTypesForFromTransforms,
- ...blockTypesForToTransforms,
- ] ),
- ];
+ const blockTypesByNameAndVariation = new Map<
+ string,
+ BlockTypeWithTransformMetadata
+ >();
+
+ for ( const blockType of [
+ ...blockTypesForFromTransforms,
+ ...blockTypesForToTransforms,
+ ] ) {
+ const key = blockType.variationName
+ ? `${ blockType.name }/${ blockType.variationName }`
+ : blockType.name;
+ if ( ! blockTypesByNameAndVariation.has( key ) ) {
+ blockTypesByNameAndVariation.set( key, blockType );
+ }
+ }
+
+ return [ ...blockTypesByNameAndVariation.values() ];
}
/**
@@ -511,14 +539,16 @@ function maybeCheckTransformIsMatch(
/**
* Switch one or more blocks into one or more blocks of the new block type.
*
- * @param blocks Blocks array or block object.
- * @param name Block name.
+ * @param blocks Blocks array or block object.
+ * @param name Block name.
+ * @param variationName Optional target block variation name.
*
* @return Array of blocks or null.
*/
export function switchToBlockType(
blocks: Block[] | Block,
- name: string
+ name: string,
+ variationName?: string
): Block[] | null {
const blocksArray = Array.isArray( blocks ) ? blocks : [ blocks ];
const isMultiBlock = blocksArray.length > 1;
@@ -529,12 +559,15 @@ export function switchToBlockType(
// transformation.
const transformationsFrom = getBlockTransforms( 'from', name );
const transformationsTo = getBlockTransforms( 'to', sourceName );
+ const isMatchingVariation = ( t: BlockTransform ) =>
+ variationName ? t.variationName === variationName : ! t.variationName;
const transformation =
findTransform(
transformationsTo,
( t ) =>
t.type === 'block' &&
+ isMatchingVariation( t ) &&
( isWildcardBlockTransform( t ) ||
t.blocks!.indexOf( name ) !== -1 ) &&
( ! isMultiBlock || !! t.isMultiBlock ) &&
@@ -544,6 +577,7 @@ export function switchToBlockType(
transformationsFrom,
( t ) =>
t.type === 'block' &&
+ isMatchingVariation( t ) &&
( isWildcardBlockTransform( t ) ||
t.blocks!.indexOf( sourceName ) !== -1 ) &&
( ! isMultiBlock || !! t.isMultiBlock ) &&
diff --git a/packages/blocks/src/api/test/factory.js b/packages/blocks/src/api/test/factory.js
index 454a5e1563d6c8..f3a09de9efb518 100644
--- a/packages/blocks/src/api/test/factory.js
+++ b/packages/blocks/src/api/test/factory.js
@@ -530,6 +530,45 @@ describe( 'block factory', () => {
expect( availableBlocks[ 0 ].name ).toBe( 'core/text-block' );
} );
+ it( 'should preserve variation metadata for possible transformations', () => {
+ registerBlockType( 'core/updated-text-block', {
+ apiVersion: 3,
+ attributes: {
+ value: {
+ type: 'string',
+ },
+ },
+ transforms: {
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'core/text-block' ],
+ variationName: 'grid',
+ transform: noop,
+ },
+ ],
+ },
+ save: noop,
+ category: 'text',
+ title: 'updated text block',
+ } );
+ registerBlockType( 'core/text-block', defaultBlockSettings );
+
+ const block = createBlock( 'core/updated-text-block', {
+ value: 'ribs',
+ } );
+
+ const availableBlocks = getPossibleBlockTransformations( [
+ block,
+ ] );
+
+ expect( availableBlocks ).toHaveLength( 1 );
+ expect( availableBlocks[ 0 ] ).toMatchObject( {
+ name: 'core/text-block',
+ variationName: 'grid',
+ } );
+ } );
+
it( 'should not show a transformation if multiple blocks are passed and the transformation is not multi block (for a "from" transform)', () => {
registerBlockType( 'core/updated-text-block', {
apiVersion: 3,
@@ -1260,6 +1299,70 @@ describe( 'block factory', () => {
} );
} );
+ it( 'should switch the blockType of a block using a variation transform', () => {
+ registerBlockType( 'core/group-block', {
+ ...defaultBlockSettings,
+ attributes: {
+ layout: {
+ type: 'object',
+ },
+ },
+ } );
+ registerBlockType( 'core/text-block', {
+ apiVersion: 3,
+ attributes: {
+ value: {
+ type: 'string',
+ },
+ },
+ transforms: {
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'core/group-block' ],
+ transform: () =>
+ createBlock( 'core/group-block', {
+ layout: { type: 'constrained' },
+ } ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'core/group-block' ],
+ variationName: 'grid',
+ transform: () =>
+ createBlock( 'core/group-block', {
+ layout: { type: 'grid' },
+ } ),
+ },
+ ],
+ },
+ save: noop,
+ category: 'text',
+ title: 'text-block',
+ } );
+
+ const block = createBlock( 'core/text-block', {
+ value: 'ribs',
+ } );
+
+ const transformedBlocks = switchToBlockType(
+ block,
+ 'core/group-block',
+ 'grid'
+ );
+ const defaultTransformedBlocks = switchToBlockType(
+ block,
+ 'core/group-block'
+ );
+
+ expect( transformedBlocks[ 0 ].attributes ).toEqual( {
+ layout: { type: 'grid' },
+ } );
+ expect( defaultTransformedBlocks[ 0 ].attributes ).toEqual( {
+ layout: { type: 'constrained' },
+ } );
+ } );
+
it( 'should return null if no transformation is found', () => {
registerBlockType(
'core/updated-text-block',
diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts
index b6519a43317cc8..83894f5027a0a1 100644
--- a/packages/blocks/src/types.ts
+++ b/packages/blocks/src/types.ts
@@ -156,6 +156,11 @@ export interface BlockTransform<
> {
type: 'block' | 'enter' | 'files' | 'prefix' | 'raw' | 'shortcode';
blocks?: string[];
+ /**
+ * The target block variation name for block transforms that produce a
+ * variation of the transformed block type.
+ */
+ variationName?: string;
priority?: number;
isMultiBlock?: boolean;
isMatch?: (