Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor classic menu conversion process #38858

Merged
merged 18 commits into from
Mar 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 82 additions & 11 deletions packages/block-library/src/navigation/edit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
Spinner,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { speak } from '@wordpress/a11y';

/**
* Internal dependencies
Expand All @@ -55,6 +56,11 @@ import UnsavedInnerBlocks from './unsaved-inner-blocks';
import NavigationMenuDeleteControl from './navigation-menu-delete-control';
import useNavigationNotice from './use-navigation-notice';
import OverlayMenuIcon from './overlay-menu-icon';
import useConvertClassicToBlockMenu, {
CLASSIC_MENU_CONVERSION_ERROR,
CLASSIC_MENU_CONVERSION_PENDING,
CLASSIC_MENU_CONVERSION_SUCCESS,
} from './use-convert-classic-menu-to-block-menu';

const EMPTY_ARRAY = [];

Expand Down Expand Up @@ -231,23 +237,40 @@ function Navigation( {
clientId
);

const {
convert,
status: classicMenuConversionStatus,
error: classicMenuConversionError,
value: classicMenuConversionResult,
} = useConvertClassicToBlockMenu( clientId );

const isConvertingClassicMenu =
getdave marked this conversation as resolved.
Show resolved Hide resolved
classicMenuConversionStatus === CLASSIC_MENU_CONVERSION_PENDING;

// The standard HTML5 tag for the block wrapper.
const TagName = 'nav';

// "placeholder" shown if:
// - we don't have a ref attribute pointing to a Navigation Post.
// - we are not running a menu conversion process.
// - we don't have uncontrolled blocks.
// - (legacy) we have a Navigation Area without a ref attribute pointing to a Navigation Post.
const isPlaceholder =
! ref && ( ! hasUncontrolledInnerBlocks || isWithinUnassignedArea );
! ref &&
! isConvertingClassicMenu &&
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No showing a placeholder if we're running the conversion process.

( ! hasUncontrolledInnerBlocks || isWithinUnassignedArea );

const isEntityAvailable =
! isNavigationMenuMissing && isNavigationMenuResolved;

// "loading" state:
// - we are running the Classic Menu conversion process.
// OR
// - there is a ref attribute pointing to a Navigation Post
// - the Navigation Post isn't available (hasn't resolved) yet.
const isLoading = !! ( ref && ! isEntityAvailable );
const isLoading =
isConvertingClassicMenu ||
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now loading UI state should show immediately if we are running the conversion.

!! ( ref && ! isEntityAvailable && ! isConvertingClassicMenu );

const blockProps = useBlockProps( {
ref: navRef,
Expand Down Expand Up @@ -310,6 +333,42 @@ function Navigation( {
] = useState();
const [ detectedOverlayColor, setDetectedOverlayColor ] = useState();

const [
showClassicMenuConversionErrorNotice,
hideClassicMenuConversionErrorNotice,
] = useNavigationNotice( {
name: 'block-library/core/navigation/classic-menu-conversion/error',
} );

function handleUpdateMenu( menuId ) {
setRef( menuId );
selectBlock( clientId );
}

useEffect( () => {
if ( classicMenuConversionStatus === CLASSIC_MENU_CONVERSION_PENDING ) {
speak( __( 'Classic menu importing.' ) );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These messages are pretty helpful, should we also add them as snackbar notifications? (In a different PR)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could yes if that's not too "noisy"? I wonder if notification popups have speak() built in?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I've found a great case where that would be very useful. When creating an empty menu it's not clear that it's been "successful". An explicit confirmation would be really helpful.

I'm working on a sister PR now which I will push up which refactors the create empty process.

}

if (
classicMenuConversionStatus === CLASSIC_MENU_CONVERSION_SUCCESS &&
classicMenuConversionResult
) {
handleUpdateMenu( classicMenuConversionResult?.id );
hideClassicMenuConversionErrorNotice();
speak( __( 'Classic menu imported successfully.' ) );
}

if ( classicMenuConversionStatus === CLASSIC_MENU_CONVERSION_ERROR ) {
showClassicMenuConversionErrorNotice( classicMenuConversionError );
speak( __( 'Classic menu import failed.' ) );
}
}, [
classicMenuConversionStatus,
classicMenuConversionResult,
classicMenuConversionError,
] );

// Spacer block needs orientation from context. This is a patch until
// https://github.com/WordPress/gutenberg/issues/36197 is addressed.
useEffect( () => {
Expand Down Expand Up @@ -388,6 +447,25 @@ function Navigation( {
ref,
] );

const handleSelectNavigation = useCallback(
( navPostOrClassicMenu ) => {
if ( ! navPostOrClassicMenu ) {
return;
}

const isClassicMenu = navPostOrClassicMenu.hasOwnProperty(
'auto_add'
);

if ( isClassicMenu ) {
convert( navPostOrClassicMenu.id, navPostOrClassicMenu.name );
} else {
handleUpdateMenu( navPostOrClassicMenu.id );
}
},
[ convert, handleUpdateMenu ]
);

const startWithEmptyMenu = useCallback( () => {
registry.batch( () => {
if ( navigationArea ) {
Expand Down Expand Up @@ -490,12 +568,7 @@ function Navigation( {
isResolvingCanUserCreateNavigationMenu={
isResolvingCanUserCreateNavigationMenu
}
onFinish={ ( post ) => {
if ( post ) {
setRef( post.id );
}
selectBlock( clientId );
} }
onFinish={ handleSelectNavigation }
/>
</TagName>
);
Expand All @@ -510,9 +583,7 @@ function Navigation( {
<NavigationMenuSelector
currentMenuId={ ref }
clientId={ clientId }
onSelect={ ( { id } ) => {
setRef( id );
} }
onSelect={ handleSelectNavigation }
onCreateNew={ startWithEmptyMenu }
/* translators: %s: The name of a menu. */
actionLabel={ __( "Switch to '%s'" ) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,9 @@ import { useCallback, useMemo } from '@wordpress/element';
*/
import useNavigationMenu from '../use-navigation-menu';
import useNavigationEntities from '../use-navigation-entities';
import useConvertClassicMenu from '../use-convert-classic-menu';
import useCreateNavigationMenu from './use-create-navigation-menu';

export default function NavigationMenuSelector( {
currentMenuId,
clientId,
onSelect,
onCreateNew,
showManageActions = false,
Expand All @@ -43,27 +40,6 @@ export default function NavigationMenuSelector( {
canSwitchNavigationMenu,
} = useNavigationMenu();

const createNavigationMenu = useCreateNavigationMenu( clientId );

const onFinishMenuCreation = async (
blocks,
navigationMenuTitle = null
) => {
if ( ! canUserCreateNavigationMenu ) {
return;
}

const navigationMenu = await createNavigationMenu(
navigationMenuTitle,
blocks
);
onSelect( navigationMenu );
};

const convertClassicMenuToBlocks = useConvertClassicMenu(
onFinishMenuCreation
);

const handleSelect = useCallback(
( _onClose ) => ( selectedId ) => {
_onClose();
Expand All @@ -74,6 +50,14 @@ export default function NavigationMenuSelector( {
[ navigationMenus ]
);

const handleSelectClassic = useCallback(
( _onClose, menu ) => () => {
_onClose();
onSelect( menu );
},
[]
);

const menuChoices = useMemo( () => {
return (
navigationMenus?.map( ( { id, title } ) => {
Expand Down Expand Up @@ -130,13 +114,10 @@ export default function NavigationMenuSelector( {
const label = decodeEntities( menu.name );
return (
<MenuItem
onClick={ () => {
onClose();
convertClassicMenuToBlocks(
menu.id,
menu.name
);
} }
onClick={ handleSelectClassic(
onClose,
menu
) }
key={ menu.id }
aria-label={ sprintf(
createActionLabel,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* WordPress dependencies
*/
import { useRegistry } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { useState, useCallback } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import useCreateNavigationMenu from './use-create-navigation-menu';
import menuItemsToBlocks from '../menu-items-to-blocks';

export const CLASSIC_MENU_CONVERSION_SUCCESS = 'success';
export const CLASSIC_MENU_CONVERSION_ERROR = 'error';
export const CLASSIC_MENU_CONVERSION_PENDING = 'pending';
export const CLASSIC_MENU_CONVERSION_IDLE = 'idle';

function useConvertClassicToBlockMenu( clientId ) {
const createNavigationMenu = useCreateNavigationMenu( clientId );
const registry = useRegistry();

const [ status, setStatus ] = useState( CLASSIC_MENU_CONVERSION_IDLE );
const [ value, setValue ] = useState( null );
const [ error, setError ] = useState( null );

async function convertClassicMenuToBlockMenu( menuId, menuName ) {
Copy link
Contributor

@adamziel adamziel Feb 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd pull it out of the hook, there's no need to construct a new function on each run and it would be more readable to me (more separation).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I take your point but if you follow the flow you'll notice that this is required to be within a hook because it uses other hooks. In fact it's hooks within hooks within hooks.

I'm not in favour of this approach really but I don't think we should look to refactor in this PR because it will become overly complex to review.

How would you feel about pairing on a refactor of this sometime?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, let's try to pair up tomorrow

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the recent updates in place I feel pretty good about having this here, let's just wrap it in useCallback.

let navigationMenu;
let classicMenuItems;

// 1. Fetch the classic Menu items.
try {
classicMenuItems = await registry
.resolveSelect( coreStore )
.getMenuItems( {
menus: menuId,
per_page: -1,
context: 'view',
} );
} catch ( err ) {
throw new Error(
sprintf(
// translators: %s: the name of a menu (e.g. Header navigation).
__( `Unable to fetch classic menu "%s" from API.` ),
menuName
),
{
cause: err,
}
);
}

// Handle offline response which resolves to `null`.
if ( classicMenuItems === null ) {
throw new Error(
sprintf(
// translators: %s: the name of a menu (e.g. Header navigation).
__( `Unable to fetch classic menu "%s" from API.` ),
menuName
)
);
}

// 2. Convert the classic items into blocks.
const { innerBlocks } = menuItemsToBlocks( classicMenuItems );

// 3. Create the `wp_navigation` Post with the blocks.
try {
navigationMenu = await createNavigationMenu(
menuName,
innerBlocks
);
} catch ( err ) {
throw new Error(
sprintf(
// translators: %s: the name of a menu (e.g. Header navigation).
__( `Unable to create Navigation Menu "%s".` ),
menuName
),
{
cause: err,
}
);
}

return navigationMenu;
}

const convert = useCallback(
( menuId, menuName ) => {
if ( ! menuId || ! menuName ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can have an async function inside useCallback, only you have to declare it separately, e.g.:

const convert = useCallback(
	( menuId, menuName ) => {
		async function doTheWork() {};
		doTheWork();
	}
);

Copy link
Contributor Author

@getdave getdave Mar 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeh but in this case the aim isn't to return Promise from convert(). We want to run a routine and then have that routine update the state which we can then use outside of the hook to decide on various things such as loading states.

This relieves the hook consumer from having to reimplementing the isFetching, isError...etc inside their component. Rather all this is encapsulated and we simply expose a state which the consumer can use to decide on side effects within a useEffect.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All I'm saying is there are two code styles available to choose from. Note that my snippet above doesn't return a promise either.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see. You're just saying we could move the async function outside of the useCallback and that would be ok.

Unless it's a blocker for you, I'd rather not do that now because I'm planning a follow up that might change things a little. I ping you when that's up.

setError( 'Unable to convert menu. Missing menu details.' );
setStatus( CLASSIC_MENU_CONVERSION_ERROR );
return;
}

setStatus( CLASSIC_MENU_CONVERSION_PENDING );
setValue( null );
setError( null );

convertClassicMenuToBlockMenu( menuId, menuName )
.then( ( navMenu ) => {
setValue( navMenu );
setStatus( CLASSIC_MENU_CONVERSION_SUCCESS );
} )
.catch( ( err ) => {
setError( err?.message );
setStatus( CLASSIC_MENU_CONVERSION_ERROR );

// Rethrow error for debugging.
Copy link
Contributor

@adamziel adamziel Mar 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, this could say just throw err since we're not going to tell the user what the error message says.

throw new Error(
sprintf(
// translators: %s: the name of a menu (e.g. Header navigation).
__( `Unable to create Navigation Menu "%s".` ),
menuName
),
{
cause: err,
}
);
} );
},
[ clientId ]
);

return {
convert,
status,
value,
error,
};
}

export default useConvertClassicToBlockMenu;
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@ import { useRef } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { store as noticeStore } from '@wordpress/notices';

function useNavigationNotice( { name, message } = {} ) {
function useNavigationNotice( { name, message = '' } = {} ) {
const noticeRef = useRef();

const { createWarningNotice, removeNotice } = useDispatch( noticeStore );

const showNotice = () => {
const showNotice = ( customMsg ) => {
getdave marked this conversation as resolved.
Show resolved Hide resolved
if ( noticeRef.current ) {
return;
}

noticeRef.current = name;

createWarningNotice( message, {
createWarningNotice( customMsg || message, {
id: noticeRef.current,
type: 'snackbar',
} );
Expand Down
Loading