Skip to content
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
206 changes: 145 additions & 61 deletions packages/block-library/src/navigation-link/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ToolbarButton,
Tooltip,
ToolbarGroup,
KeyboardShortcuts,
} from '@wordpress/components';
import { displayShortcut, isKeyboardEvent, ENTER } from '@wordpress/keycodes';
import { __, sprintf } from '@wordpress/i18n';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -329,6 +388,7 @@ export default function NavigationLinkEdit( {
clientId,
} ) {
const {
id,
label,
type,
opensInNewTab,
Expand All @@ -339,6 +399,8 @@ export default function NavigationLinkEdit( {
kind,
} = attributes;

const [ isInvalid, isDraft ] = useIsInvalidLink( kind, type, id );

const link = {
url,
opensInNewTab,
Expand Down Expand Up @@ -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 (
<Fragment>
Expand Down Expand Up @@ -677,46 +726,81 @@ export default function NavigationLinkEdit( {
{ /* eslint-enable */ }
{ ! url ? (
<div className="wp-block-navigation-link__placeholder-text">
<Tooltip
position="top center"
text={ __( 'This item is missing a link' ) }
>
<span>{ missingText }</span>
<Tooltip position="top center" text={ tooltipText }>
<>
<span>{ missingText }</span>
<span className="wp-block-navigation-link__missing_text-tooltip">
{ tooltipText }
</span>
</>
</Tooltip>
</div>
) : (
<RichText
ref={ ref }
identifier="label"
className="wp-block-navigation-item__label"
value={ label }
onChange={ ( labelValue ) =>
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 && (
<RichText
ref={ ref }
identifier="label"
className="wp-block-navigation-item__label"
value={ label }
onChange={ ( labelValue ) =>
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 ) && (
<div className="wp-block-navigation-link__placeholder-text wp-block-navigation-link__label">
<KeyboardShortcuts
shortcuts={ {
enter: () =>
isSelected &&
setIsLinkOpen( true ),
} }
/>
<Tooltip
position="top center"
text={ tooltipText }
>
<>
<span>
{
/* Trim to avoid trailing white space when the placeholder text is not present */
`${ label } ${ placeholderText }`.trim()
}
</span>
<span className="wp-block-navigation-link__missing_text-tooltip">
{ tooltipText }
</span>
</>
</Tooltip>
</div>
) }
</>
) }
{ isLinkOpen && (
<Popover
Expand Down
11 changes: 11 additions & 0 deletions packages/block-library/src/navigation-link/editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,18 @@
}
}

.wp-block-navigation-link__invalid-item {
color: #000;
}

.wp-block-navigation-link__missing_text-tooltip {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
}
/**
* Menu item setup state. Is shown when a menu item has no URL configured.
*/
Expand Down
54 changes: 53 additions & 1 deletion packages/e2e-tests/specs/editor/blocks/navigation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,47 @@ const POSTS_ENDPOINT = '/wp/v2/posts';
const PAGES_ENDPOINT = '/wp/v2/pages';
const DRAFT_PAGES_ENDPOINT = [ PAGES_ENDPOINT, { status: 'draft' } ];
const NAVIGATION_MENUS_ENDPOINT = '/wp/v2/navigation';
// todo: consolidate with logic found in navigation-editor tests
// https://github.com/WordPress/gutenberg/blob/trunk/packages/e2e-tests/specs/experiments/navigation-editor.test.js#L71
const REST_PAGES_ROUTES = [
'/wp/v2/pages',
`rest_route=${ encodeURIComponent( '/wp/v2/pages' ) }`,
];

/**
* Determines if a given URL matches any of a given collection of
* routes (expressed as substrings).
*
* @param {string} reqUrl the full URL to be tested for matches.
* @param {Array} routes array of strings to match against the URL.
*/
function matchUrlToRoute( reqUrl, routes ) {
return routes.some( ( route ) => 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 ) => ( {
Expand All @@ -47,14 +88,25 @@ async function mockSearchResponse( items ) {
type: 'post',
url: `https://this/is/a/test/search/${ slug }`,
} ) );

await setUpResponseMocking( [
{
match: ( request ) =>
request.url("").includes( `rest_route` ) &&
request.url("").includes( `search` ),
onRequestMatch: createJSONResponse( mappedItems ),
},
...getPagesMocks( {
GET: [
{
type: 'page',
id: 1,
link: 'https://example.com/1',
title: {
rendered: 'My page',
},
},
],
} ),
] );
}

Expand Down