diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index a1ce7bb2781d9c..8903aac594c763 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -523,10 +523,10 @@ export default function NavigationLinkEdit( { title: newTitle = '', url: newURL = '', opensInNewTab: newOpensInNewTab, - id, + id: newId, kind: newKind = '', type: newType = '', - } = {} ) => + } = {} ) => { setAttributes( { url: encodeURI( newURL ), label: ( () => { @@ -552,7 +552,12 @@ export default function NavigationLinkEdit( { return escape( normalizedURL ); } )(), opensInNewTab: newOpensInNewTab, - id, + // `id` represents the DB ID of the entity which this link represents (eg: Post ID). + // Therefore we must not inadvertently set it to `undefined` if the `onChange` is called with no `id` value. + // This is possible when a setting changes such as the `opensInNewTab`. + ...( newId && { + id: newId, + } ), ...( newKind && { kind: newKind, } ), @@ -561,8 +566,8 @@ export default function NavigationLinkEdit( { newType !== 'post-format' && { type: newType, } ), - } ) - } + } ); + } } /> ) } diff --git a/packages/block-library/src/navigation/map-menu-items-to-blocks.js b/packages/block-library/src/navigation/map-menu-items-to-blocks.js new file mode 100644 index 00000000000000..4b21aaa9838819 --- /dev/null +++ b/packages/block-library/src/navigation/map-menu-items-to-blocks.js @@ -0,0 +1,107 @@ +/** + * WordPress dependencies + */ +import { createBlock, parse } from '@wordpress/blocks'; + +/** + * A WP nav_menu_item object. + * For more documentation on the individual fields present on a menu item please see: + * https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L789 + * + * Changes made here should also be mirrored in packages/edit-navigation/src/store/utils.js. + * + * @typedef WPNavMenuItem + * + * @property {Object} title stores the raw and rendered versions of the title/label for this menu item. + * @property {Array} xfn the XFN relationships expressed in the link of this menu item. + * @property {Array} classes the HTML class attributes for this menu item. + * @property {string} attr_title the HTML title attribute for this menu item. + * @property {string} object The type of object originally represented, such as 'category', 'post', or 'attachment'. + * @property {string} object_id The DB ID of the original object this menu item represents, e.g. ID for posts and term_id for categories. + * @property {string} description The description of this menu item. + * @property {string} url The URL to which this menu item points. + * @property {string} type The family of objects originally represented, such as 'post_type' or 'taxonomy'. + * @property {string} target The target attribute of the link element for this menu item. + */ + +/** + * Convert block attributes to menu item. + * + * @param {WPNavMenuItem} menuItem the menu item to be converted to block attributes. + * @return {Object} the block attributes converted from the WPNavMenuItem item. + */ +export const menuItemToBlockAttributes = ( { + title: menuItemTitleField, + xfn, + classes, + // eslint-disable-next-line camelcase + attr_title, + object, + // eslint-disable-next-line camelcase + object_id, + description, + url, + type: menuItemTypeField, + target, +} ) => { + return { + label: menuItemTitleField?.rendered || '', + ...( object?.length && { + type: object, + } ), + kind: menuItemTypeField?.replace( '_', '-' ) || 'custom', + url: url || '', + ...( xfn?.length && + xfn.join( ' ' ).trim() && { + rel: xfn.join( ' ' ).trim(), + } ), + ...( classes?.length && + classes.join( ' ' ).trim() && { + className: classes.join( ' ' ).trim(), + } ), + ...( attr_title?.length && { + title: attr_title, + } ), + // eslint-disable-next-line camelcase + ...( object_id && + 'custom' !== object && { + id: object_id, + } ), + ...( description?.length && { + description, + } ), + ...( target === '_blank' && { + opensInNewTab: true, + } ), + }; +}; + +/** + * A recursive function that maps menu item nodes to blocks. + * + * @param {Object[]} menuItems An array of menu items. + * @return {WPBlock[]} An array of blocks. + */ +export default function mapMenuItemsToBlocks( menuItems ) { + return menuItems.map( ( menuItem ) => { + if ( menuItem.type === 'block' ) { + const [ block ] = parse( menuItem.content.raw ); + + if ( ! block ) { + return createBlock( 'core/freeform', { + content: menuItem.content, + } ); + } + + return block; + } + + const attributes = menuItemToBlockAttributes( menuItem ); + + const innerBlocks = menuItem.children?.length + ? mapMenuItemsToBlocks( menuItem.children ) + : []; + + return createBlock( 'core/navigation-link', attributes, innerBlocks ); + } ); +} diff --git a/packages/block-library/src/navigation/placeholder.js b/packages/block-library/src/navigation/placeholder.js index c0ac1b6ff4c301..aabb615d12ab1f 100644 --- a/packages/block-library/src/navigation/placeholder.js +++ b/packages/block-library/src/navigation/placeholder.js @@ -1,12 +1,7 @@ -/** - * External dependencies - */ -import { some } from 'lodash'; - /** * WordPress dependencies */ -import { createBlock, parse } from '@wordpress/blocks'; +import { createBlock } from '@wordpress/blocks'; import { Button, DropdownMenu, @@ -29,59 +24,9 @@ import { store as coreStore } from '@wordpress/core-data'; * Internal dependencies */ import createDataTree from './create-data-tree'; +import mapMenuItemsToBlocks from './map-menu-items-to-blocks'; import PlaceholderPreview from './placeholder-preview'; -/** - * A recursive function that maps menu item nodes to blocks. - * - * @param {Object[]} menuItems An array of menu items. - * @return {WPBlock[]} An array of blocks. - */ -function mapMenuItemsToBlocks( menuItems ) { - return menuItems.map( ( menuItem ) => { - if ( menuItem.type === 'block' ) { - const [ block ] = parse( menuItem.content.raw ); - - if ( ! block ) { - return createBlock( 'core/freeform', { - content: menuItem.content, - } ); - } - - return block; - } - - const attributes = { - label: ! menuItem.title.rendered - ? __( '(no title)' ) - : menuItem.title.rendered, - opensInNewTab: menuItem.target === '_blank', - }; - - if ( menuItem.url ) { - attributes.url = menuItem.url; - } - - if ( menuItem.description ) { - attributes.description = menuItem.description; - } - - if ( menuItem.xfn?.length && some( menuItem.xfn ) ) { - attributes.rel = menuItem.xfn.join( ' ' ); - } - - if ( menuItem.classes?.length && some( menuItem.classes ) ) { - attributes.className = menuItem.classes.join( ' ' ); - } - - const innerBlocks = menuItem.children?.length - ? mapMenuItemsToBlocks( menuItem.children ) - : []; - - return createBlock( 'core/navigation-link', attributes, innerBlocks ); - } ); -} - /** * Convert a flat menu item structure to a nested blocks structure. * diff --git a/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap b/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap index 730e2a283f2118..e9c360213219d0 100644 --- a/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap +++ b/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap @@ -10,36 +10,36 @@ exports[`Navigation editor allows creation of a menu when there are no current m exports[`Navigation editor displays the first menu from the REST response when at least one menu exists 1`] = ` " - + - - + + - - - - - + + + + + - + - + - - - - + + + + - - + + " `; diff --git a/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap b/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap index 74860922357e8d..52204753521c4a 100644 --- a/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap +++ b/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap @@ -2,36 +2,36 @@ exports[`Navigation Creating from existing Menus allows a navigation block to be created from existing menus 1`] = ` " - + - - + + - - - - - + + + + + - + - + - - - - + + + + - - + + " `; diff --git a/packages/edit-navigation/src/store/resolvers.js b/packages/edit-navigation/src/store/resolvers.js index 798f7db0581958..462d4ee26572d8 100644 --- a/packages/edit-navigation/src/store/resolvers.js +++ b/packages/edit-navigation/src/store/resolvers.js @@ -11,14 +11,10 @@ import { parse, createBlock } from '@wordpress/blocks'; /** * Internal dependencies */ -import { - NAVIGATION_POST_KIND, - NAVIGATION_POST_POST_TYPE, - NEW_TAB_TARGET_ATTRIBUTE, -} from '../constants'; +import { NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE } from '../constants'; import { resolveMenuItems, dispatch } from './controls'; -import { buildNavigationPostId } from './utils'; +import { buildNavigationPostId, menuItemToBlockAttributes } from './utils'; /** * Creates a "stub" navigation post reflecting the contents of menu with id=menuId. The @@ -106,6 +102,7 @@ function createNavigationBlock( menuItems ) { } const sortedItems = sortBy( items, 'menu_order' ); + for ( const item of sortedItems ) { let menuItemInnerBlocks = []; if ( itemsByParentID[ item.id ]?.length ) { @@ -145,17 +142,7 @@ function convertMenuItemToBlock( menuItem, innerBlocks = [] ) { return createBlock( block.name, block.attributes, innerBlocks ); } - const attributes = { - label: menuItem.title.rendered, - url: menuItem.url, - title: menuItem.attr_title, - className: menuItem.classes.join( ' ' ), - description: menuItem.description, - rel: menuItem.xfn.join( ' ' ), - ...( menuItem.target === NEW_TAB_TARGET_ATTRIBUTE && { - opensInNewTab: true, - } ), - }; + const attributes = menuItemToBlockAttributes( menuItem ); return createBlock( 'core/navigation-link', attributes, innerBlocks ); } diff --git a/packages/edit-navigation/src/store/test/resolvers.js b/packages/edit-navigation/src/store/test/resolvers.js index 26c85cfd48dbd4..fdbe8eddc97e5d 100644 --- a/packages/edit-navigation/src/store/test/resolvers.js +++ b/packages/edit-navigation/src/store/test/resolvers.js @@ -108,6 +108,25 @@ describe( 'getNavigationPostForMenu', () => { attr_title: '', target: '_blank', }, + { + id: 102, + title: { + raw: 'My Example Page', + rendered: 'My Example Page', + }, + url: '/my-example-page/', + object: 'page', + object_id: 56789, + type: 'post-type', + menu_order: 3, + menus: [ 1 ], + parent: 0, + classes: [], + xfn: [], + description: '', + attr_title: '', + target: '_blank', + }, ]; expect( generator.next( menuItems ).value ).toEqual( { @@ -116,6 +135,7 @@ describe( 'getNavigationPostForMenu', () => { mapping: { 100: expect.stringMatching( /client-id-\d+/ ), 101: expect.stringMatching( /client-id-\d+/ ), + 102: expect.stringMatching( /client-id-\d+/ ), }, } ); @@ -139,6 +159,8 @@ describe( 'getNavigationPostForMenu', () => { rel: 'nofollow', description: 'description', title: 'link title', + kind: 'custom', + type: 'custom', }, clientId: 'client-id-0', innerBlocks: [], @@ -148,16 +170,27 @@ describe( 'getNavigationPostForMenu', () => { attributes: { label: 'wp.org', url: 'http://wp.org', - className: '', - rel: '', - description: '', - title: '', opensInNewTab: true, + kind: 'custom', + type: 'custom', }, clientId: 'client-id-1', innerBlocks: [], name: 'core/navigation-link', }, + { + attributes: { + label: 'My Example Page', + url: '/my-example-page/', + opensInNewTab: true, + kind: 'post-type', + type: 'page', + id: 56789, + }, + clientId: 'client-id-2', + innerBlocks: [], + name: 'core/navigation-link', + }, ], name: 'core/navigation', }, @@ -189,4 +222,108 @@ describe( 'getNavigationPostForMenu', () => { expect( generator.next().done ).toBe( true ); } ); + + it( 'creates correct core/navigation-link block variations from menu objects', () => { + const menuId = 123; + + const generator = getNavigationPostForMenu( menuId ); + + // Advance generator + generator.next(); // Gen step: yield persistPost + generator.next(); // Gen step: yield dispatch "getEntityRecord" + generator.next(); // Gen step: yield resolveMenuItems + + const menuItems = [ + { + id: 100, + title: { + raw: 'wp.com', + rendered: 'wp.com', + }, + url: 'http://wp.com', + menu_order: 1, + menus: [ 1 ], + parent: 0, + classes: [ 'menu', 'classes' ], + xfn: [ 'nofollow' ], + description: 'description', + attr_title: 'link title', + object: 'post', + object_id: 123, // the post object ID not the menu object ID + }, + { + id: 101, + title: { + raw: 'wp.org', + rendered: 'wp.org', + }, + url: 'http://wp.org', + menu_order: 2, + menus: [ 1 ], + parent: 0, + classes: [], + xfn: [], + description: '', + attr_title: '', + object: 'page', + object_id: 456, // the page object ID not the menu object ID + }, + { + id: 102, + title: { + raw: 'wordpress.org', + rendered: 'wordpress.org', + }, + url: 'https://wordpress.org', + menu_order: 3, + menus: [ 1 ], + parent: 0, + classes: [], + xfn: [], + description: '', + attr_title: '', + object: 'custom', + }, + ]; + + // // Gen step: yield 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', + // By feeding `menuItems` to the generator this will overload the **result** of + // the call to yield resolveMenuItems( menuId ); + generator.next( menuItems ); + + // Gen step: yield persistPost + const persistPostAction = generator.next().value; + + // Get the core/navigation-link blocks from the generated core/navigation block innerBlocks. + const blockAttrs = persistPostAction.args[ 2 ].blocks[ 0 ].innerBlocks.map( + ( block ) => block.attributes + ); + + // Post link + expect( blockAttrs[ 0 ] ).toEqual( + expect.objectContaining( { + id: 123, + type: 'post', + } ) + ); + + // Page link + expect( blockAttrs[ 1 ] ).toEqual( + expect.objectContaining( { + id: 456, + type: 'page', + } ) + ); + + // Custom link + expect( blockAttrs[ 2 ] ).toEqual( + expect.objectContaining( { + type: 'custom', + } ) + ); + + // We should not manually create an ID unless the menu object + // has a `object_id` field set. + expect( blockAttrs[ 2 ].id ).toBeUndefined(); + } ); } ); diff --git a/packages/edit-navigation/src/store/test/utils.js b/packages/edit-navigation/src/store/test/utils.js index 44d03900b85771..180b2b45f20f35 100644 --- a/packages/edit-navigation/src/store/test/utils.js +++ b/packages/edit-navigation/src/store/test/utils.js @@ -6,6 +6,8 @@ import { menuItemsQuery, serializeProcessing, computeCustomizedAttribute, + blockAttributesToMenuItem, + menuItemToBlockAttributes, } from '../utils'; import { isProcessingPost, @@ -218,6 +220,8 @@ describe( 'computeCustomizedAttribute', () => { url: 'http://wp.org', className: 'block classnames', rel: 'external', + type: 'custom', + kind: 'custom', }, clientId: 'navigation-link-block-client-id-1', innerBlocks: [], @@ -231,12 +235,30 @@ describe( 'computeCustomizedAttribute', () => { url: 'http://wp.com', className: '', rel: '', + type: 'custom', + kind: 'custom', }, clientId: 'navigation-link-block-client-id-2', innerBlocks: [], isValid: true, name: 'core/navigation-link', }, + { + attributes: { + id: 678, + label: 'Page Example', + opensInNewTab: false, + url: 'https://localhost:8889/page-example/', + className: '', + rel: '', + type: 'page', + kind: 'post-type', + }, + clientId: 'navigation-link-block-client-id-3', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, ]; const menuId = 123; @@ -244,23 +266,32 @@ describe( 'computeCustomizedAttribute', () => { const menuItemsByClientId = { 'navigation-link-block-client-id-1': { id: 100, - title: { - raw: 'wp.com', - rendered: 'wp.com', - }, + title: 'wp.com', url: 'http://wp.com', menu_order: 1, menus: [ 1 ], + object: 'custom', + original_title: '', }, 'navigation-link-block-client-id-2': { id: 101, - title: { - raw: 'wp.org', - rendered: 'wp.org', - }, + title: 'wp.org', url: 'http://wp.org', menu_order: 2, menus: [ 1 ], + object: 'custom', + original_title: '', + }, + 'navigation-link-block-client-id-3': { + id: 102, + title: 'Page Example', + url: 'https://wordpress.org', + menu_order: 3, + menus: [ 1 ], + object: 'page', + object_id: 678, + type: 'post_type', + original_title: '', }, }; @@ -281,17 +312,17 @@ describe( 'computeCustomizedAttribute', () => { menu_order: 1, nav_menu_term_id: 123, original_title: '', + object: 'custom', position: 1, status: 'publish', title: 'wp.org', - type: 'custom', url: 'http://wp.org', xfn: [ 'external' ], + type: 'custom', target: '', }, 'nav_menu_item[101]': { _invalid: false, - classes: [ '' ], id: 101, menu_item_parent: 0, menu_order: 2, @@ -300,11 +331,543 @@ describe( 'computeCustomizedAttribute', () => { position: 2, status: 'publish', title: 'wp.com', - type: 'custom', + object: 'custom', url: 'http://wp.com', + target: '_blank', + type: 'custom', + }, + 'nav_menu_item[102]': { + _invalid: false, + id: 102, + menu_item_parent: 0, + menu_order: 3, + nav_menu_term_id: 123, + original_title: '', + position: 3, + status: 'publish', + title: 'Page Example', + object: 'page', // equivalent: block.attributes.type + object_id: 678, // equivalent: block.attributes.id + type: 'post_type', // // equivalent: block.attributes.kind + url: 'https://localhost:8889/page-example/', + target: '', + }, + } ); + } ); +} ); + +describe( 'Mapping block attributes and menu item fields', () => { + const blocksToMenuItems = [ + { + block: { + attributes: { + label: 'Example Page', + url: '/example-page/', + description: 'Lorem ipsum dolor sit amet.', + rel: 'friend met', + className: 'my-custom-class-one my-custom-class-two', + title: 'Example page link title attribute', + id: 100, + type: 'page', + kind: 'post-type', + opensInNewTab: true, + }, + clientId: 'navigation-link-block-client-id-1', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + menuItem: { + title: 'Example Page', + url: '/example-page/', + description: 'Lorem ipsum dolor sit amet.', + xfn: [ 'friend', 'met' ], + classes: [ 'my-custom-class-one', 'my-custom-class-two' ], + attr_title: 'Example page link title attribute', + object_id: 100, + object: 'page', + type: 'post_type', + target: '_blank', + }, + }, + { + block: { + attributes: { + label: 'Example Post', + url: '/example-post/', + description: 'Consectetur adipiscing elit.', + rel: 'friend', + className: 'my-custom-class-one', + title: 'Example post link title attribute', + id: 101, + type: 'post', + kind: 'post-type', + opensInNewTab: false, + }, + clientId: 'navigation-link-block-client-id-2', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + menuItem: { + title: 'Example Post', + url: '/example-post/', + description: 'Consectetur adipiscing elit.', + xfn: [ 'friend' ], + classes: [ 'my-custom-class-one' ], + attr_title: 'Example post link title attribute', + object_id: 101, + object: 'post', + type: 'post_type', + target: '', + }, + }, + { + block: { + attributes: { + label: 'Example Category', + url: '/example-category/', + description: + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + rel: '', + className: '', + title: '', + id: 102, + type: 'category', + kind: 'taxonomy', + opensInNewTab: true, + }, + clientId: 'navigation-link-block-client-id-3', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + menuItem: { + title: 'Example Category', + url: '/example-category/', + description: + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + object_id: 102, + object: 'category', + type: 'taxonomy', + target: '_blank', + }, + }, + { + block: { + attributes: { + label: 'Example Tag', + url: '/example-tag/', + description: '', + rel: '', + className: '', + title: '', + id: 103, + type: 'tag', + kind: 'taxonomy', + opensInNewTab: false, + }, + clientId: 'navigation-link-block-client-id-4', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + menuItem: { + title: 'Example Tag', + url: '/example-tag/', + object_id: 103, + object: 'post_tag', + type: 'taxonomy', + target: '', + }, + }, + { + block: { + attributes: { + label: 'Example Custom Link', + url: 'https://wordpress.org', + description: '', + rel: '', + className: '', + title: '', + type: 'custom', + kind: 'custom', + opensInNewTab: true, + }, + clientId: 'navigation-link-block-client-id-5', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + menuItem: { + title: 'Example Custom Link', + url: 'https://wordpress.org', + object: 'custom', + type: 'custom', + target: '_blank', + }, + }, + ]; + + const menuItemsToBlockAttrs = [ + { + menuItem: { + title: { + raw: 'Example Page', + rendered: 'Example Page', + }, + url: '/example-page/', + description: 'Lorem ipsum dolor sit amet.', + xfn: [ 'friend', 'met' ], + classes: [ 'my-custom-class-one', 'my-custom-class-two' ], + attr_title: 'Example page link title attribute', + object_id: 100, + object: 'page', + type: 'post_type', + target: '_blank', + }, + blockAttrs: { + label: 'Example Page', + url: '/example-page/', + description: 'Lorem ipsum dolor sit amet.', + rel: 'friend met', + className: 'my-custom-class-one my-custom-class-two', + title: 'Example page link title attribute', + id: 100, + type: 'page', + kind: 'post-type', + opensInNewTab: true, + }, + }, + { + menuItem: { + title: { + raw: 'Example Post', + rendered: 'Example Post', + }, + url: '/example-post/', + description: 'Consectetur adipiscing elit.', + xfn: [ 'friend' ], + classes: [ 'my-custom-class-one' ], + attr_title: 'Example post link title attribute', + object_id: 101, + object: 'post', + type: 'post_type', + target: '', + }, + blockAttrs: { + label: 'Example Post', + url: '/example-post/', + description: 'Consectetur adipiscing elit.', + rel: 'friend', + className: 'my-custom-class-one', + title: 'Example post link title attribute', + id: 101, + type: 'post', + kind: 'post-type', + }, + }, + { + menuItem: { + title: { + raw: 'Example Category', + rendered: 'Example Category', + }, + url: '/example-category/', + description: + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + xfn: [ ' ', ' ' ], + classes: [ ' ', ' ' ], + attr_title: '', + object_id: 102, + object: 'category', + type: 'taxonomy', + target: '_blank', + }, + blockAttrs: { + label: 'Example Category', + url: '/example-category/', + description: + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + id: 102, + type: 'category', + kind: 'taxonomy', + opensInNewTab: true, + }, + }, + { + menuItem: { + title: { + raw: 'Example Tag', + rendered: 'Example Tag', + }, + url: '/example-tag/', + description: '', xfn: [ '' ], + classes: [ '' ], + attr_title: '', + object_id: 103, + object: 'tag', + type: 'taxonomy', + target: '', + }, + blockAttrs: { + label: 'Example Tag', + url: '/example-tag/', + id: 103, + type: 'tag', + kind: 'taxonomy', + }, + }, + { + menuItem: { + title: { + raw: 'Example Custom Link', + rendered: 'Example Custom Link', + }, + url: 'https://wordpress.org', + description: '', + xfn: [ '' ], + classes: [ '' ], + attr_title: '', + object: 'custom', + type: 'custom', target: '_blank', }, + blockAttrs: { + label: 'Example Custom Link', + url: 'https://wordpress.org', + type: 'custom', + kind: 'custom', + opensInNewTab: true, + }, + }, + ]; + + describe( 'mapping block attributes to menu item fields', () => { + it( 'maps block attributes to equivalent menu item fields', () => { + const [ actual, expected ] = blocksToMenuItems.reduce( + ( acc, item ) => { + acc[ 0 ].push( + blockAttributesToMenuItem( item.block.attributes ) + ); + acc[ 1 ].push( item.menuItem ); + return acc; + }, + [ [], [] ] + ); + + expect( actual ).toEqual( expected ); + } ); + + it( 'does not map block attribute "id" to menu item "object_id" field for custom (non-entity) links', () => { + const customLinkBlockAttributes = { + id: 12345, // added for test purposes only - should't exist. + type: 'custom', // custom type indicates we shouldn't need an `id` field. + kind: 'custom', // custom type indicates we shouldn't need an `id` field. + label: 'Example Custom Link', + url: 'https://wordpress.org', + description: '', + rel: '', + className: '', + title: '', + opensInNewTab: true, + }; + + const actual = blockAttributesToMenuItem( + customLinkBlockAttributes + ); + + // Check the basic conversion to menuItem happened successfully. + expect( actual ).toEqual( { + title: 'Example Custom Link', + url: 'https://wordpress.org', + object: 'custom', + type: 'custom', + target: '_blank', + } ); + + // Assert `id` attr has NOT been converted to a `object_id` field for a "custom" type even if present. + expect( actual.object_id ).toBeUndefined(); + } ); + + it( 'correctly maps "tag" block type variation to "post_tag" value as expected in "object" type field', () => { + const tagLinkBlockVariation = { + id: 12345, // added for test purposes only - should't exist. + type: 'tag', // custom type indicates we shouldn't need an `id` field. + kind: 'taxonomy', // custom type indicates we shouldn't need an `id` field. + label: 'Example Tag', + url: '/example-tag/', + }; + + const actual = blockAttributesToMenuItem( tagLinkBlockVariation ); + + expect( actual.object ).toBe( 'post_tag' ); + } ); + + it( 'gracefully handles undefined values by falling back to menu item defaults', () => { + const blockAttrsWithUndefinedValues = { + id: undefined, + type: undefined, + kind: undefined, + label: undefined, + url: undefined, + description: undefined, + rel: undefined, + className: undefined, + title: undefined, + opensInNewTab: undefined, + }; + + const actual = blockAttributesToMenuItem( + blockAttrsWithUndefinedValues + ); + + // Defaults are taken from https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L438. + expect( actual ).toEqual( + expect.objectContaining( { + title: '', + url: '', + } ) + ); + + // Remaining values should not be present. + expect( Object.keys( actual ) ).not.toEqual( + expect.arrayContaining( [ + 'description', + 'xfn', + 'classes', + 'attr_title', + 'object', + 'type', + 'object_id', + 'target', + ] ) + ); + + expect( Object.values( actual ) ).not.toContain( undefined ); + } ); + + it( 'allows for setting and unsetting of target property based on opensInNewTab arttribute boolean', () => { + const shared = { + id: 12345, // added for test purposes only - should't exist. + type: 'custom', // custom type indicates we shouldn't need an `id` field. + kind: 'custom', // custom type indicates we shouldn't need an `id` field. + label: 'Example', + url: '/example/', + }; + + const openInNewTabBlock = { + ...shared, + opensInNewTab: true, + }; + + const doNotOpenInNewTabBlock = { + ...shared, + opensInNewTab: false, + }; + + const shouldOpenInNewTab = blockAttributesToMenuItem( + openInNewTabBlock + ); + + const shouldNotOpenInNewTab = blockAttributesToMenuItem( + doNotOpenInNewTabBlock + ); + + expect( shouldOpenInNewTab.target ).toBe( '_blank' ); + + // Should also allow unsetting of an existing value. + expect( shouldNotOpenInNewTab.target ).toBe( '' ); + } ); + } ); + + describe( 'mapping menu item fields to block attributes', () => { + it( 'maps menu item fields to equivalent block attributes', () => { + const [ actual, expected ] = menuItemsToBlockAttrs.reduce( + ( acc, item ) => { + acc[ 0 ].push( menuItemToBlockAttributes( item.menuItem ) ); + acc[ 1 ].push( item.blockAttrs ); + return acc; + }, + [ [], [] ] + ); + + expect( actual ).toEqual( expected ); + } ); + + it( 'does not map menu item "object_id" field to block attribute "id" for custom (non-entity) links', () => { + const customLinkMenuItem = { + title: 'Example Custom Link', + url: 'https://wordpress.org', + description: '', + xfn: [ '' ], + classes: [ '' ], + attr_title: '', + object_id: 123456, // added for test purposes. + object: 'custom', + type: 'custom', + target: '_blank', + }; + const actual = menuItemToBlockAttributes( customLinkMenuItem ); + + expect( actual.id ).toBeUndefined(); + } ); + + it( 'correctly maps "post_tag" menu item object type to "tag" block type variation', () => { + const tagMenuItem = { + title: 'Example Tag', + url: '/example-tag/', + object_id: 123456, + object: 'post_tag', + type: 'taxonomy', + }; + + const actual = menuItemToBlockAttributes( tagMenuItem ); + + expect( actual.type ).toBe( 'tag' ); + } ); + + it( 'gracefully handles undefined values by falling back to block attribute defaults', () => { + // Note that whilst Core provides default values for nav_menu_item's it is possible that these + // values could be manipulated via Plugins. As such we must account for unexpected values. + const menuItemsWithUndefinedValues = { + title: undefined, + url: undefined, + description: undefined, + xfn: undefined, + classes: undefined, + attr_title: undefined, + object_id: undefined, + object: undefined, + type: undefined, + target: undefined, + }; + + const actual = menuItemToBlockAttributes( + menuItemsWithUndefinedValues + ); + + expect( actual ).toEqual( + expect.objectContaining( { + label: '', + type: 'custom', + kind: 'custom', + } ) + ); + + expect( Object.keys( actual ) ).not.toEqual( + expect.arrayContaining( [ + 'rel', + 'className', + 'title', + 'id', + 'description', + 'opensInNewTab', + ] ) + ); + + expect( Object.values( actual ) ).not.toContain( undefined ); } ); } ); } ); diff --git a/packages/edit-navigation/src/store/utils.js b/packages/edit-navigation/src/store/utils.js index 60a0418d163fd1..3d26c111ca4a29 100644 --- a/packages/edit-navigation/src/store/utils.js +++ b/packages/edit-navigation/src/store/utils.js @@ -19,6 +19,27 @@ import { import { NEW_TAB_TARGET_ATTRIBUTE } from '../constants'; +/** + * A WP nav_menu_item object. + * For more documentation on the individual fields present on a menu item please see: + * https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L789 + * + * Changes made here should also be mirrored in packages/block-library/src/navigation/map-menu-items-to-blocks.js. + * + * @typedef WPNavMenuItem + * + * @property {Object} title stores the raw and rendered versions of the title/label for this menu item. + * @property {Array} xfn the XFN relationships expressed in the link of this menu item. + * @property {Array} classes the HTML class attributes for this menu item. + * @property {string} attr_title the HTML title attribute for this menu item. + * @property {string} object The type of object originally represented, such as 'category', 'post', or 'attachment'. + * @property {string} object_id The DB ID of the original object this menu item represents, e.g. ID for posts and term_id for categories. + * @property {string} description The description of this menu item. + * @property {string} url The URL to which this menu item points. + * @property {string} type The family of objects originally represented, such as 'post_type' or 'taxonomy'. + * @property {string} target The target attribute of the link element for this menu item. + */ + /** * Builds an ID for a new navigation post. * @@ -117,7 +138,6 @@ export function computeCustomizedAttribute( dataObject[ key ] = false; } } - return JSON.stringify( dataObject ); function blocksTreeToFlatList( innerBlocks, parentId = 0 ) { @@ -137,19 +157,7 @@ export function computeCustomizedAttribute( let attributes; if ( block.name === 'core/navigation-link' ) { - attributes = { - type: 'custom', - title: block.attributes?.label, - original_title: '', - url: block.attributes.url, - description: block.attributes.description, - xfn: block.attributes.rel?.split( ' ' ), - classes: block.attributes.className?.split( ' ' ), - attr_title: block.attributes.title, - target: block.attributes.opensInNewTab - ? NEW_TAB_TARGET_ATTRIBUTE - : '', - }; + attributes = blockAttributesToMenuItem( block.attributes ); } else { attributes = { type: 'block', @@ -172,3 +180,132 @@ export function computeCustomizedAttribute( return omit( menuItemsByClientId[ block.clientId ] || {}, '_links' ); } } + +/** + * Convert block attributes to menu item fields. + * + * Note that nav_menu_item has defaults provided in Core so in the case of undefined Block attributes + * we need only include a subset of values in the knowledge that the defaults will be provided in Core. + * + * See: https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L438. + * + * @param {Object} blockAttributes the block attributes of the block to be converted into menu item fields. + * @param {string} blockAttributes.label the visual name of the block shown in the UI. + * @param {string} blockAttributes.url the URL for the link. + * @param {string} blockAttributes.description a link description. + * @param {string} blockAttributes.rel the XFN relationship expressed in the link of this menu item. + * @param {string} blockAttributes.className the custom CSS classname attributes for this block. + * @param {string} blockAttributes.title the HTML title attribute for the block's link. + * @param {string} blockAttributes.type the type of variation of the block used (eg: 'Post', 'Custom', 'Category'...etc). + * @param {number} blockAttributes.id the ID of the entity optionally associated with the block's link (eg: the Post ID). + * @param {string} blockAttributes.kind the family of objects originally represented, such as 'post-type' or 'taxonomy'. + * @param {boolean} blockAttributes.opensInNewTab whether or not the block's link should open in a new tab. + * @return {Object} the menu item (converted from block attributes). + */ +export const blockAttributesToMenuItem = ( { + label = '', + url = '', + description, + rel, + className, + title: blockTitleAttr, + type, + id, + kind, + opensInNewTab, +} ) => { + // For historical reasons, the `core/navigation-link` variation type is `tag` + // whereas WP Core expects `post_tag` as the `object` type. + // To avoid writing a block migration we perform a conversion here. + // See also inverse equivalent in `menuItemToBlockAttributes`. + if ( type && type === 'tag' ) { + type = 'post_tag'; + } + + return { + title: label, + url, + ...( description?.length && { + description, + } ), + ...( rel?.length && { + xfn: rel?.trim().split( ' ' ), + } ), + ...( className?.length && { + classes: className?.trim().split( ' ' ), + } ), + ...( blockTitleAttr?.length && { + attr_title: blockTitleAttr, + } ), + ...( type?.length && { + object: type, + } ), + ...( kind?.length && { + type: kind?.replace( '-', '_' ), + } ), + // Only assign object_id if it's a entity type (ie: not "custom"). + ...( id && + 'custom' !== type && { + object_id: id, + } ), + target: opensInNewTab ? NEW_TAB_TARGET_ATTRIBUTE : '', + }; +}; + +/** + * Convert block attributes to menu item. + * + * @param {WPNavMenuItem} menuItem the menu item to be converted to block attributes. + * @return {Object} the block attributes converted from the menu item. + */ +export const menuItemToBlockAttributes = ( { + title: menuItemTitleField, + xfn, + classes, + // eslint-disable-next-line camelcase + attr_title, + object, + // eslint-disable-next-line camelcase + object_id, + description, + url, + type: menuItemTypeField, + target, +} ) => { + // For historical reasons, the `core/navigation-link` variation type is `tag` + // whereas WP Core expects `post_tag` as the `object` type. + // To avoid writing a block migration we perform a conversion here. + // See also inverse equivalent in `blockAttributesToMenuItem`. + if ( object && object === 'post_tag' ) { + object = 'tag'; + } + + return { + label: menuItemTitleField?.rendered || '', + type: object || 'custom', + kind: menuItemTypeField?.replace( '_', '-' ) || 'custom', + url: url || '', + ...( xfn?.length && + xfn.join( ' ' ).trim() && { + rel: xfn.join( ' ' ).trim(), + } ), + ...( classes?.length && + classes.join( ' ' ).trim() && { + className: classes.join( ' ' ).trim(), + } ), + ...( attr_title?.length && { + title: attr_title, + } ), + // eslint-disable-next-line camelcase + ...( object_id && + 'custom' !== object && { + id: object_id, + } ), + ...( description?.length && { + description, + } ), + ...( target === NEW_TAB_TARGET_ATTRIBUTE && { + opensInNewTab: true, + } ), + }; +};