Skip to content

Commit

Permalink
Update navigation editor placeholder (#34568)
Browse files Browse the repository at this point in the history
* Implement placeholder

* Filter out selected menu

* Update tests

* Fix empty dropdown when there are no other menus

* Use useSelectedMenuId hook

Co-authored-by: George Mamadashvili <[email protected]>

* Remove duplicate import comments

* Styling adjustments

* Remove duplicate comment

* Use nav editor implementation of menuItemToBlockAttributes

* Update dependencies

Co-authored-by: George Mamadashvili <[email protected]>
  • Loading branch information
talldan and Mamaduka authored Sep 7, 2021
1 parent 3c70093 commit 083e28d
Show file tree
Hide file tree
Showing 11 changed files with 554 additions and 6 deletions.
9 changes: 7 additions & 2 deletions packages/block-library/src/navigation/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ function Navigation( {
hasSubmenuIndicatorSetting = true,
hasItemJustificationControls = true,
hasColorSettings = true,
customPlaceholder: CustomPlaceholder = null,
} ) {
const [ isPlaceholderShown, setIsPlaceholderShown ] = useState(
! hasExistingNavItems
Expand Down Expand Up @@ -163,7 +164,7 @@ function Navigation( {
// inherit templateLock={ 'all' }.
templateLock: false,
__experimentalLayout: LAYOUT,
placeholder,
placeholder: ! CustomPlaceholder ? placeholder : undefined,
}
);

Expand Down Expand Up @@ -200,9 +201,13 @@ function Navigation( {
} );

if ( isPlaceholderShown ) {
const PlaceholderComponent = CustomPlaceholder
? CustomPlaceholder
: NavigationPlaceholder;

return (
<div { ...blockProps }>
<NavigationPlaceholder
<PlaceholderComponent
onCreate={ ( blocks, selectNavigationBlock ) => {
setIsPlaceholderShown( false );
updateInnerBlocks( blocks );
Expand Down
2 changes: 1 addition & 1 deletion packages/block-library/src/navigation/placeholder.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function NavigationPlaceholder( { onCreate }, ref ) {
const { innerBlocks: blocks } = menuItemsToBlocks( menuItems );
const selectNavigationBlock = true;
onCreate( blocks, selectNavigationBlock );
} );
}, [ menuItems, menuItemsToBlocks, onCreate ] );

const onCreateFromMenu = () => {
// If we have menu items, create the block right away.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exports[`Navigation editor allows creation of a menu when there are existing men

exports[`Navigation editor allows creation of a menu when there are no current menu items 1`] = `
"<!-- wp:navigation {\\"orientation\\":\\"vertical\\"} -->
<!-- wp:page-list {\\"isNavigationChild\\":true} /-->
<!-- wp:navigation-link {\\"label\\":\\"My page\\",\\"type\\":\\"page\\",\\"id\\":1,\\"url\\":\\"https://example.com/1\\",\\"isTopLevelLink\\":true} /-->
<!-- /wp:navigation -->"
`;

Expand Down
15 changes: 13 additions & 2 deletions packages/e2e-tests/specs/experiments/navigation-editor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,18 @@ describe( 'Navigation editor', () => {
POST: menuPostResponse,
} ),
...getMenuItemMocks( { GET: [] } ),
...getPagesMocks( { GET: [ {} ] } ), // mock a single page
...getPagesMocks( {
GET: [
{
type: 'page',
id: 1,
link: 'https://example.com/1',
title: {
rendered: 'My page',
},
},
],
} ),
] );

await page.keyboard.type( 'Main Menu' );
Expand Down Expand Up @@ -354,7 +365,7 @@ describe( 'Navigation editor', () => {
);
await navBlock.click();
const startEmptyButton = await page.waitForXPath(
'//button[.="Start empty"]'
'//button[.="Start blank"]'
);
await startEmptyButton.click();

Expand Down
187 changes: 187 additions & 0 deletions packages/edit-navigation/src/components/block-placeholder/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* WordPress dependencies
*/
import { createBlock } from '@wordpress/blocks';
import {
Placeholder,
Button,
DropdownMenu,
MenuGroup,
MenuItem,
Spinner,
} from '@wordpress/components';
import {
forwardRef,
useCallback,
useState,
useEffect,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { chevronDown } from '@wordpress/icons';

/**
* Internal dependencies
*/
import { useMenuEntityProp, useSelectedMenuId } from '../../hooks';
import useNavigationEntities from './use-navigation-entities';
import menuItemsToBlocks from './menu-items-to-blocks';

/**
* Convert pages to blocks.
*
* @param {Object[]} pages An array of pages.
*
* @return {WPBlock[]} An array of blocks.
*/
function convertPagesToBlocks( pages ) {
if ( ! pages?.length ) {
return null;
}

return pages.map( ( { title, type, link: url, id } ) =>
createBlock( 'core/navigation-link', {
type,
id,
url,
label: ! title.rendered ? __( '(no title)' ) : title.rendered,
opensInNewTab: false,
} )
);
}

const TOGGLE_PROPS = { variant: 'tertiary' };
const POPOVER_PROPS = { position: 'bottom center' };

function BlockPlaceholder( { onCreate }, ref ) {
const [ selectedMenu, setSelectedMenu ] = useState();
const [ isCreatingFromMenu, setIsCreatingFromMenu ] = useState( false );

const [ selectedMenuId ] = useSelectedMenuId();
const [ menuName ] = useMenuEntityProp( 'name', selectedMenuId );

const {
isResolvingPages,
menus,
isResolvingMenus,
menuItems,
hasResolvedMenuItems,
pages,
hasPages,
hasMenus,
} = useNavigationEntities( selectedMenu );

const isLoading = isResolvingPages || isResolvingMenus;

const createFromMenu = useCallback( () => {
const { innerBlocks: blocks } = menuItemsToBlocks( menuItems );
const selectNavigationBlock = true;
onCreate( blocks, selectNavigationBlock );
}, [ menuItems, menuItemsToBlocks, onCreate ] );

const onCreateFromMenu = () => {
// If we have menu items, create the block right away.
if ( hasResolvedMenuItems ) {
createFromMenu();
return;
}

// Otherwise, create the block when resolution finishes.
setIsCreatingFromMenu( true );
};

const onCreateEmptyMenu = () => {
onCreate( [] );
};

const onCreateAllPages = () => {
const blocks = convertPagesToBlocks( pages );
const selectNavigationBlock = true;
onCreate( blocks, selectNavigationBlock );
};

useEffect( () => {
// If the user selected a menu but we had to wait for menu items to
// finish resolving, then create the block once resolution finishes.
if ( isCreatingFromMenu && hasResolvedMenuItems ) {
createFromMenu();
setIsCreatingFromMenu( false );
}
}, [ isCreatingFromMenu, hasResolvedMenuItems ] );

const selectableMenus = menus?.filter(
( menu ) => menu.id !== selectedMenuId
);

const hasSelectableMenus = !! selectableMenus?.length;

return (
<Placeholder
className="edit-navigation-block-placeholder"
label={ menuName }
instructions={ __(
'This menu is empty. You can start blank and choose what to add,' +
' add your existing pages, or add the content of another menu.'
) }
>
<div className="edit-navigation-block-placeholder__controls">
{ isLoading && (
<div ref={ ref }>
<Spinner />
</div>
) }
{ ! isLoading && (
<div
ref={ ref }
className="edit-navigation-block-placeholder__actions"
>
<Button
variant="tertiary"
onClick={ onCreateEmptyMenu }
>
{ __( 'Start blank' ) }
</Button>
{ hasPages ? (
<Button
variant={ hasMenus ? 'tertiary' : 'primary' }
onClick={ onCreateAllPages }
>
{ __( 'Add all pages' ) }
</Button>
) : undefined }
{ hasSelectableMenus ? (
<DropdownMenu
text={ __( 'Copy existing menu' ) }
icon={ chevronDown }
toggleProps={ TOGGLE_PROPS }
popoverProps={ POPOVER_PROPS }
>
{ ( { onClose } ) => (
<MenuGroup>
{ selectableMenus.map( ( menu ) => {
return (
<MenuItem
onClick={ () => {
setSelectedMenu(
menu.id
);
onCreateFromMenu();
} }
onClose={ onClose }
key={ menu.id }
>
{ menu.name }
</MenuItem>
);
} ) }
</MenuGroup>
) }
</DropdownMenu>
) : undefined }
</div>
) }
</div>
</Placeholder>
);
}

export default forwardRef( BlockPlaceholder );
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* External dependencies
*/
import { sortBy } from 'lodash';

/**
* WordPress dependencies
*/
import { createBlock } from '@wordpress/blocks';

/**
* Internal dependencies
*/
import { menuItemToBlockAttributes } from '../../store/utils';

/**
* Convert a flat menu item structure to a nested blocks structure.
*
* @param {Object[]} menuItems An array of menu items.
*
* @return {WPBlock[]} An array of blocks.
*/
export default function menuItemsToBlocks( menuItems ) {
if ( ! menuItems ) {
return null;
}

const menuTree = createDataTree( menuItems );
return mapMenuItemsToBlocks( menuTree );
}

/** @typedef {import('../..store/utils').WPNavMenuItem} WPNavMenuItem */

/**
* A recursive function that maps menu item nodes to blocks.
*
* @param {WPNavMenuItem[]} menuItems An array of WPNavMenuItem items.
* @return {Object} Object containing innerBlocks and mapping.
*/
function mapMenuItemsToBlocks( menuItems ) {
let mapping = {};

// The menuItem should be in menu_order sort order.
const sortedItems = sortBy( menuItems, 'menu_order' );

const innerBlocks = sortedItems.map( ( menuItem ) => {
const attributes = menuItemToBlockAttributes( menuItem );

// If there are children recurse to build those nested blocks.
const {
innerBlocks: nestedBlocks = [], // alias to avoid shadowing
mapping: nestedMapping = {}, // alias to avoid shadowing
} = menuItem.children?.length
? mapMenuItemsToBlocks( menuItem.children )
: {};

// Update parent mapping with nested mapping.
mapping = {
...mapping,
...nestedMapping,
};

// Create block with nested "innerBlocks".
const block = createBlock(
'core/navigation-link',
attributes,
nestedBlocks
);

// Create mapping for menuItem -> block
mapping[ menuItem.id ] = block.clientId;

return block;
} );

return {
innerBlocks,
mapping,
};
}

/**
* Creates a nested, hierarchical tree representation from unstructured data that
* has an inherent relationship defined between individual items.
*
* For example, by default, each element in the dataset should have an `id` and
* `parent` property where the `parent` property indicates a relationship between
* the current item and another item with a matching `id` properties.
*
* This is useful for building linked lists of data from flat data structures.
*
* @param {Array} dataset linked data to be rearranged into a hierarchical tree based on relational fields.
* @param {string} id the property which uniquely identifies each entry within the array.
* @param {*} relation the property which identifies how the current item is related to other items in the data (if at all).
* @return {Array} a nested array of parent/child relationships
*/
function createDataTree( dataset, id = 'id', relation = 'parent' ) {
const hashTable = Object.create( null );
const dataTree = [];

for ( const data of dataset ) {
hashTable[ data[ id ] ] = {
...data,
children: [],
};
}
for ( const data of dataset ) {
if ( data[ relation ] ) {
hashTable[ data[ relation ] ].children.push(
hashTable[ data[ id ] ]
);
} else {
dataTree.push( hashTable[ data[ id ] ] );
}
}

return dataTree;
}
Loading

0 comments on commit 083e28d

Please sign in to comment.