diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index 68142bdc05c4a7..43cab0244b9406 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -18,6 +18,7 @@ import { ToolbarButton, Tooltip, ToolbarGroup, + KeyboardShortcuts, } from '@wordpress/components'; import { displayShortcut, isKeyboardEvent, ENTER } from '@wordpress/keycodes'; import { __, sprintf } from '@wordpress/i18n'; @@ -265,6 +266,64 @@ export const updateNavigationLinkBlockAttributes = ( } ); }; +const useIsInvalidLink = ( kind, type, id ) => { + const isPostType = + kind === 'post-type' || type === 'post' || type === 'page'; + const hasId = Number.isInteger( id ); + const postStatus = useSelect( + ( select ) => { + if ( ! isPostType ) { + return null; + } + const { getEntityRecord } = select( coreStore ); + return getEntityRecord( 'postType', type, id )?.status; + }, + [ isPostType, type, id ] + ); + + // Check Navigation Link validity if: + // 1. Link is 'post-type'. + // 2. It has an id. + // 3. It's neither null, nor undefined, as valid items might be either of those while loading. + // If those conditions are met, check if + // 1. The post status is published. + // 2. The Navigation Link item has no label. + // If either of those is true, invalidate. + const isInvalid = + isPostType && hasId && postStatus && 'trash' === postStatus; + const isDraft = 'draft' === postStatus; + + return [ isInvalid, isDraft ]; +}; + +const useMissingText = ( type ) => { + let missingText = ''; + + switch ( type ) { + case 'post': + /* translators: label for missing post in navigation link block */ + missingText = __( 'Select post' ); + break; + case 'page': + /* translators: label for missing page in navigation link block */ + missingText = __( 'Select page' ); + break; + case 'category': + /* translators: label for missing category in navigation link block */ + missingText = __( 'Select category' ); + break; + case 'tag': + /* translators: label for missing tag in navigation link block */ + missingText = __( 'Select tag' ); + break; + default: + /* translators: label for missing values in navigation link block */ + missingText = __( 'Add link' ); + } + + return missingText; +}; + /** * Removes HTML from a given string. * Note the does not provide XSS protection or otherwise attempt @@ -329,6 +388,7 @@ export default function NavigationLinkEdit( { clientId, } ) { const { + id, label, type, opensInNewTab, @@ -339,6 +399,8 @@ export default function NavigationLinkEdit( { kind, } = attributes; + const [ isInvalid, isDraft ] = useIsInvalidLink( kind, type, id ); + const link = { url, opensInNewTab, @@ -589,36 +651,23 @@ export default function NavigationLinkEdit( { onKeyDown, } ); - if ( ! url ) { + if ( ! url || isInvalid || isDraft ) { blockProps.onClick = () => setIsLinkOpen( true ); } const classes = classnames( 'wp-block-navigation-item__content', { - 'wp-block-navigation-link__placeholder': ! url, + 'wp-block-navigation-link__placeholder': ! url || isInvalid || isDraft, } ); - let missingText = ''; - switch ( type ) { - case 'post': - /* translators: label for missing post in navigation link block */ - missingText = __( 'Select post' ); - break; - case 'page': - /* translators: label for missing page in navigation link block */ - missingText = __( 'Select page' ); - break; - case 'category': - /* translators: label for missing category in navigation link block */ - missingText = __( 'Select category' ); - break; - case 'tag': - /* translators: label for missing tag in navigation link block */ - missingText = __( 'Select tag' ); - break; - default: - /* translators: label for missing values in navigation link block */ - missingText = __( 'Add link' ); - } + const missingText = useMissingText( type, isInvalid, isDraft ); + /* translators: Whether the navigation link is Invalid or a Draft. */ + const placeholderText = `(${ + isInvalid ? __( 'Invalid' ) : __( 'Draft' ) + })`; + const tooltipText = + isInvalid || isDraft + ? __( 'This item has been deleted, or is a draft' ) + : __( 'This item is missing a link' ); return ( @@ -677,46 +726,81 @@ export default function NavigationLinkEdit( { { /* eslint-enable */ } { ! url ? (
- - { missingText } + + <> + { missingText } + + { tooltipText } + +
) : ( - - setAttributes( { - label: labelValue, - } ) - } - onMerge={ mergeBlocks } - onReplace={ onReplace } - __unstableOnSplitAtEnd={ () => - insertBlocksAfter( - createBlock( 'core/navigation-link' ) - ) - } - aria-label={ __( 'Navigation link text' ) } - placeholder={ itemLabelPlaceholder } - withoutInteractiveFormatting - allowedFormats={ [ - 'core/bold', - 'core/italic', - 'core/image', - 'core/strikethrough', - ] } - onClick={ () => { - if ( ! url ) { - setIsLinkOpen( true ); - } - } } - /> + <> + { ! isInvalid && ! isDraft && ( + + setAttributes( { + label: labelValue, + } ) + } + onMerge={ mergeBlocks } + onReplace={ onReplace } + __unstableOnSplitAtEnd={ () => + insertBlocksAfter( + createBlock( + 'core/navigation-link' + ) + ) + } + aria-label={ __( 'Navigation link text' ) } + placeholder={ itemLabelPlaceholder } + withoutInteractiveFormatting + allowedFormats={ [ + 'core/bold', + 'core/italic', + 'core/image', + 'core/strikethrough', + ] } + onClick={ () => { + if ( ! url ) { + setIsLinkOpen( true ); + } + } } + /> + ) } + { ( isInvalid || isDraft ) && ( +
+ + isSelected && + setIsLinkOpen( true ), + } } + /> + + <> + + { + /* Trim to avoid trailing white space when the placeholder text is not present */ + `${ label } ${ placeholderText }`.trim() + } + + + { tooltipText } + + + +
+ ) } + ) } { isLinkOpen && ( reqUrl.includes( route ) ); +} + +function getEndpointMocks( matchingRoutes, responsesByMethod ) { + return [ 'GET', 'POST', 'DELETE', 'PUT' ].reduce( ( mocks, restMethod ) => { + if ( responsesByMethod[ restMethod ] ) { + return [ + ...mocks, + { + match: ( request ) => + matchUrlToRoute( request.url(), matchingRoutes ) && + request.method() === restMethod, + onRequestMatch: createJSONResponse( + responsesByMethod[ restMethod ] + ), + }, + ]; + } + + return mocks; + }, [] ); +} + +function getPagesMocks( responsesByMethod ) { + return getEndpointMocks( REST_PAGES_ROUTES, responsesByMethod ); +} async function mockSearchResponse( items ) { const mappedItems = items.map( ( { title, slug }, index ) => ( { @@ -48,7 +89,6 @@ async function mockSearchResponse( items ) { type: 'post', url: `https://this/is/a/test/search/${ slug }`, } ) ); - await setUpResponseMocking( [ { match: ( request ) => @@ -56,6 +96,18 @@ async function mockSearchResponse( items ) { request.url().includes( `search` ), onRequestMatch: createJSONResponse( mappedItems ), }, + ...getPagesMocks( { + GET: [ + { + type: 'page', + id: 1, + link: 'https://example.com/1', + title: { + rendered: 'My page', + }, + }, + ], + } ), ] ); }