@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
571 lines (498 loc) • 15.5 kB
text/typescript
import { Y } from '@wordpress/sync';
import { renderHook } from '@testing-library/react';
import {
getBlockPathInYdoc,
getContainingBlockYMap,
resolveBlockClientIdByPath,
usePostContentBlocks,
} from '../block-lookup';
import type { EditorStoreBlock } from '../block-lookup';
type MockBlock = EditorStoreBlock & {
name: string;
innerBlocks: MockBlock[];
};
let mockGetClientIdsTree: jest.Mock;
function mockFlattenBlocks( blocks: MockBlock[] ): MockBlock[] {
return blocks.flatMap( ( b ) => [
b,
...mockFlattenBlocks( b.innerBlocks ),
] );
}
jest.mock( '../../lock-unlock', () => ( {
unlock: ( obj: any ) => obj,
} ) );
jest.mock( '@wordpress/data', () => ( {
useSelect: ( selector: Function ) =>
selector( () => ( {
getClientIdsTree: ( ...args: any[] ) =>
mockGetClientIdsTree( ...args ),
getBlocksByName: ( blockName: string ) =>
mockFlattenBlocks( mockGetClientIdsTree( '' ) )
.filter( ( b ) => b.name === blockName )
.map( ( b ) => b.clientId ),
} ) ),
} ) );
jest.mock( '@wordpress/block-editor', () => ( {
store: 'core/block-editor',
} ) );
/**
* Create a Y.Map block with a clientId and empty innerBlocks, matching the
* shape used by the Yjs block tree.
*
* @param clientId Block client ID.
*/
function createTestYBlock( clientId: string ): Y.Map< any > {
const block = new Y.Map< any >();
block.set( 'clientId', clientId );
block.set( 'innerBlocks', new Y.Array< Y.Map< any > >() );
return block;
}
/**
* Create a Y.Map block with a clientId and the given innerBlocks array,
* for blocks that need pre-populated children.
*
* @param clientId Block client ID.
* @param innerBlocks A Y.Array to use as the block's innerBlocks.
*/
function createTestYBlockWithInner(
clientId: string,
innerBlocks: Y.Array< Y.Map< any > >
): Y.Map< any > {
const block = new Y.Map< any >();
block.set( 'clientId', clientId );
block.set( 'innerBlocks', innerBlocks );
return block;
}
/**
* Helper to create a Y.Doc with a flat list of blocks, each having a
* `clientId` and an empty `innerBlocks` Y.Array.
*
* @param count Number of root blocks to create.
* @return The Y.Doc and the root blocks Y.Array.
*/
function createFlatYDoc( count: number ) {
const ydoc = new Y.Doc();
const rootMap = ydoc.getMap( 'test' );
const blocks = new Y.Array< Y.Map< any > >();
rootMap.set( 'blocks', blocks );
const yBlocks: Y.Map< any >[] = [];
for ( let i = 0; i < count; i++ ) {
yBlocks.push( createTestYBlock( `block-${ i }` ) );
}
blocks.push( yBlocks );
return { ydoc, blocks };
}
/**
* Helper to create a Y.Doc with nested blocks.
*
* Creates `rootCount` root blocks, then adds `innerCount` inner blocks
* to the block at index `parentIndex`.
* @param root0
* @param root0.rootCount
* @param root0.parentIndex
* @param root0.innerCount
*/
function createNestedYDoc( {
rootCount,
parentIndex,
innerCount,
}: {
rootCount: number;
parentIndex: number;
innerCount: number;
} ) {
const ydoc = new Y.Doc();
const rootMap = ydoc.getMap( 'test' );
const rootBlocks = new Y.Array< Y.Map< any > >();
rootMap.set( 'blocks', rootBlocks );
const yRootBlocks: Y.Map< any >[] = [];
for ( let i = 0; i < rootCount; i++ ) {
yRootBlocks.push( createTestYBlock( `root-${ i }` ) );
}
rootBlocks.push( yRootBlocks );
// Add inner blocks to the specified parent.
const parentBlock = rootBlocks.get( parentIndex );
const innerBlocksArray = parentBlock.get( 'innerBlocks' ) as Y.Array<
Y.Map< any >
>;
const yInnerBlocks: Y.Map< any >[] = [];
for ( let j = 0; j < innerCount; j++ ) {
yInnerBlocks.push(
createTestYBlock( `inner-${ parentIndex }-${ j }` )
);
}
innerBlocksArray.push( yInnerBlocks );
return { ydoc, rootBlocks, innerBlocksArray };
}
describe( 'getBlockPathInYdoc', () => {
it( 'should return path [0] for the first root block', () => {
const { blocks } = createFlatYDoc( 3 );
const firstBlock = blocks.get( 0 );
expect( getBlockPathInYdoc( firstBlock ) ).toEqual( [ 0 ] );
} );
it( 'should return path [2] for the third root block', () => {
const { blocks } = createFlatYDoc( 3 );
const thirdBlock = blocks.get( 2 );
expect( getBlockPathInYdoc( thirdBlock ) ).toEqual( [ 2 ] );
} );
it( 'should return a nested path for an inner block', () => {
const { innerBlocksArray } = createNestedYDoc( {
rootCount: 2,
parentIndex: 1,
innerCount: 3,
} );
// Second inner block of the second root block → [1, 1]
const innerBlock = innerBlocksArray.get( 1 );
expect( getBlockPathInYdoc( innerBlock ) ).toEqual( [ 1, 1 ] );
} );
it( 'should return [parentIndex, 0] for the first inner block', () => {
const { innerBlocksArray } = createNestedYDoc( {
rootCount: 3,
parentIndex: 0,
innerCount: 2,
} );
const firstInner = innerBlocksArray.get( 0 );
expect( getBlockPathInYdoc( firstInner ) ).toEqual( [ 0, 0 ] );
} );
it( 'should return null for a Y.Map without a parent array', () => {
const orphan = new Y.Map< any >();
orphan.set( 'clientId', 'orphan' );
expect( getBlockPathInYdoc( orphan ) ).toBeNull();
} );
it( 'should handle deeply nested blocks', () => {
// Build a 3-level deep structure so the target block is at [2, 7, 1].
const ydoc = new Y.Doc();
const rootMap = ydoc.getMap( 'test' );
const rootBlocks = new Y.Array< Y.Map< any > >();
rootMap.set( 'blocks', rootBlocks );
// 3 root blocks — the target parent is at index 2.
const innerArray = new Y.Array< Y.Map< any > >();
rootBlocks.push( [
createTestYBlock( 'root-0' ),
createTestYBlock( 'root-1' ),
createTestYBlockWithInner( 'root-2', innerArray ),
] );
// 8 inner blocks inside root-2 — the target parent is at index 7.
const grandchildArray = new Y.Array< Y.Map< any > >();
const fillerInners: Y.Map< any >[] = [];
for ( let i = 0; i < 7; i++ ) {
fillerInners.push( createTestYBlock( `inner-${ i }` ) );
}
innerArray.push( [
...fillerInners,
createTestYBlockWithInner( 'inner-7', grandchildArray ),
] );
// 2 grandchildren inside inner-7 — the target is at index 1.
const grandchild = createTestYBlock( 'target' );
grandchildArray.push( [
createTestYBlock( 'grandchild-0' ),
grandchild,
] );
expect( getBlockPathInYdoc( grandchild ) ).toEqual( [ 2, 7, 1 ] );
} );
} );
describe( 'getContainingBlockYMap', () => {
it( 'should find the containing block for direct rich text content', () => {
const block = createTestYBlock( 'block' );
const attributes = new Y.Map< any >();
const text = new Y.Text( 'Direct text' );
attributes.set( 'content', text );
block.set( 'attributes', attributes );
const ydoc = new Y.Doc();
const rootMap = ydoc.getMap( 'test' );
const blocks = new Y.Array< Y.Map< any > >();
rootMap.set( 'blocks', blocks );
blocks.push( [ block ] );
expect( getContainingBlockYMap( text ) ).toBe( block );
} );
it( 'should find the containing block for deeply nested rich text attributes', () => {
const block = createTestYBlock( 'block' );
const attributes = new Y.Map< any >();
const cards = new Y.Array< Y.Map< any > >();
const card = new Y.Map< any >();
const meta = new Y.Map< any >();
const caption = new Y.Text( 'Nested caption' );
meta.set( 'caption', caption );
card.set( 'meta', meta );
cards.push( [ card ] );
attributes.set( 'cards', cards );
block.set( 'attributes', attributes );
const ydoc = new Y.Doc();
const rootMap = ydoc.getMap( 'test' );
const blocks = new Y.Array< Y.Map< any > >();
rootMap.set( 'blocks', blocks );
blocks.push( [ block ] );
expect( getContainingBlockYMap( caption ) ).toBe( block );
} );
it( 'should return null when no block ancestor exists', () => {
const orphanAttributes = new Y.Map< any >();
const text = new Y.Text( 'Orphan text' );
orphanAttributes.set( 'content', text );
expect( getContainingBlockYMap( text ) ).toBeNull();
} );
it( 'should skip nested attribute maps that look like blocks', () => {
const block = createTestYBlock( 'block' );
const attributes = new Y.Map< any >();
const blockLikeAttribute = new Y.Map< any >();
const text = new Y.Text( 'Nested text' );
blockLikeAttribute.set( 'clientId', 'attribute-client-id' );
blockLikeAttribute.set( 'innerBlocks', new Y.Array() );
blockLikeAttribute.set( 'content', text );
attributes.set( 'nested', blockLikeAttribute );
block.set( 'attributes', attributes );
const ydoc = new Y.Doc();
const rootMap = ydoc.getMap( 'test' );
const blocks = new Y.Array< Y.Map< any > >();
rootMap.set( 'blocks', blocks );
blocks.push( [ block ] );
expect( getContainingBlockYMap( text ) ).toBe( block );
} );
} );
describe( 'resolveBlockClientIdByPath', () => {
it( 'should return null for an empty path', () => {
expect( resolveBlockClientIdByPath( [], [] ) ).toBeNull();
} );
it( 'should resolve a root block by single-element path', () => {
const blocks: MockBlock[] = [
{ clientId: 'a', name: 'core/paragraph', innerBlocks: [] },
{ clientId: 'b', name: 'core/heading', innerBlocks: [] },
];
expect( resolveBlockClientIdByPath( [ 0 ], blocks ) ).toBe( 'a' );
expect( resolveBlockClientIdByPath( [ 1 ], blocks ) ).toBe( 'b' );
} );
it( 'should return null for an out-of-bounds index', () => {
const blocks: MockBlock[] = [
{ clientId: 'a', name: 'core/paragraph', innerBlocks: [] },
];
expect( resolveBlockClientIdByPath( [ 5 ], blocks ) ).toBeNull();
} );
it( 'should resolve a nested inner block', () => {
const blocks: MockBlock[] = [
{
clientId: 'parent',
name: 'core/group',
innerBlocks: [
{
clientId: 'child-0',
name: 'core/paragraph',
innerBlocks: [],
},
{
clientId: 'child-1',
name: 'core/heading',
innerBlocks: [],
},
],
},
];
expect( resolveBlockClientIdByPath( [ 0, 1 ], blocks ) ).toBe(
'child-1'
);
} );
it( 'should return null when inner path index is out of bounds', () => {
const blocks: MockBlock[] = [
{
clientId: 'parent',
name: 'core/group',
innerBlocks: [
{
clientId: 'child-0',
name: 'core/paragraph',
innerBlocks: [],
},
],
},
];
expect( resolveBlockClientIdByPath( [ 0, 5 ], blocks ) ).toBeNull();
} );
describe( 'template mode (usePostContentBlocks behavior)', () => {
it( 'should navigate through core/post-content in template mode', () => {
const postContentInnerBlocks: MockBlock[] = [
{
clientId: 'post-para-0',
name: 'core/paragraph',
innerBlocks: [],
},
{
clientId: 'post-para-1',
name: 'core/heading',
innerBlocks: [],
},
];
// Template structure: header → post-content → footer.
// post-content's innerBlocks are empty in the tree (controlled
// inner blocks), so getClientIdsTree( postContentClientId ) is used.
const templateBlocks: MockBlock[] = [
{
clientId: 'header',
name: 'core/template-part',
innerBlocks: [],
},
{
clientId: 'post-content',
name: 'core/post-content',
innerBlocks: [], // Empty — controlled inner blocks.
},
{
clientId: 'footer',
name: 'core/template-part',
innerBlocks: [],
},
];
// Override getClientIdsTree to return post content blocks when
// called with the post-content clientId (mimicking controlled
// inner blocks behavior in useBlockSync).
mockGetClientIdsTree = jest.fn( ( rootClientId: string = '' ) => {
if ( rootClientId === '' ) {
return templateBlocks;
}
if ( rootClientId === 'post-content' ) {
return postContentInnerBlocks;
}
return [];
} );
// The returned blocks should be post content blocks, not the template blocks.
const blocks = renderHook( () => usePostContentBlocks() ).result
.current;
expect( resolveBlockClientIdByPath( [ 0 ], blocks ) ).toBe(
'post-para-0'
);
expect( resolveBlockClientIdByPath( [ 1 ], blocks ) ).toBe(
'post-para-1'
);
} );
it( 'should call getClientIdsTree with post-content clientId', () => {
const templateBlocks: MockBlock[] = [
{
clientId: 'header',
name: 'core/template-part',
innerBlocks: [],
},
{
clientId: 'pc',
name: 'core/post-content',
innerBlocks: [],
},
];
mockGetClientIdsTree = jest.fn( ( rootClientId: string = '' ) => {
if ( rootClientId === '' ) {
return templateBlocks;
}
if ( rootClientId === 'pc' ) {
return [
{
clientId: 'inner',
name: 'core/paragraph',
innerBlocks: [],
},
];
}
return [];
} );
renderHook( () => usePostContentBlocks() );
// Verify getClientIdsTree was called with the post-content clientId.
expect( mockGetClientIdsTree ).toHaveBeenCalledWith( 'pc' );
} );
it( 'should find core/post-content nested inside template parts', () => {
// post-content can be nested inside other blocks in the
// template tree (e.g. inside a group or template part).
const postContentInnerBlocks: MockBlock[] = [
{
clientId: 'deep-para',
name: 'core/paragraph',
innerBlocks: [],
},
];
const templateBlocks: MockBlock[] = [
{
clientId: 'group',
name: 'core/group',
innerBlocks: [
{
clientId: 'nested-pc',
name: 'core/post-content',
innerBlocks: [],
},
],
},
];
mockGetClientIdsTree = jest.fn( ( rootClientId: string = '' ) => {
if ( rootClientId === '' ) {
return templateBlocks;
}
if ( rootClientId === 'nested-pc' ) {
return postContentInnerBlocks;
}
return [];
} );
const blocks = renderHook( () => usePostContentBlocks() ).result
.current;
expect( resolveBlockClientIdByPath( [ 0 ], blocks ) ).toBe(
'deep-para'
);
} );
it( 'should use root blocks directly when no core/post-content exists', () => {
// No template mode — plain post editing.
const postContentBlocks: MockBlock[] = [
{
clientId: 'para-0',
name: 'core/paragraph',
innerBlocks: [],
},
{
clientId: 'para-1',
name: 'core/heading',
innerBlocks: [],
},
];
mockGetClientIdsTree = jest.fn( ( rootClientId: string = '' ) => {
if ( rootClientId === '' ) {
return postContentBlocks;
}
return [];
} );
const blocks = renderHook( () => usePostContentBlocks() ).result
.current;
expect( resolveBlockClientIdByPath( [ 0 ], blocks ) ).toBe(
'para-0'
);
expect( resolveBlockClientIdByPath( [ 1 ], blocks ) ).toBe(
'para-1'
);
} );
it( 'should return null for invalid path in template mode', () => {
const templateBlocks: MockBlock[] = [
{
clientId: 'header',
name: 'core/template-part',
innerBlocks: [],
},
{
clientId: 'pc',
name: 'core/post-content',
innerBlocks: [],
},
];
mockGetClientIdsTree = jest.fn( ( rootClientId: string = '' ) => {
if ( rootClientId === '' ) {
return templateBlocks;
}
if ( rootClientId === 'pc' ) {
// Post content has only one block.
return [
{
clientId: 'only-block',
name: 'core/paragraph',
innerBlocks: [],
},
];
}
return [];
} );
const blocks = renderHook( () => usePostContentBlocks() ).result
.current;
// Index 1 is out of bounds for the post content blocks.
expect( resolveBlockClientIdByPath( [ 1 ], blocks ) ).toBeNull();
} );
} );
} );