diff --git a/packages/block-editor/src/components/link-control/link-preview.js b/packages/block-editor/src/components/link-control/link-preview.js index 0a89f2a92ca578..630073f733d0ed 100644 --- a/packages/block-editor/src/components/link-control/link-preview.js +++ b/packages/block-editor/src/components/link-control/link-preview.js @@ -11,7 +11,9 @@ import { Button, ExternalLink, __experimentalTruncate as Truncate, + __experimentalHStack as HStack, Flex, + privateApis as componentsPrivateApis, } from '@wordpress/components'; import { useCopyToClipboard } from '@wordpress/compose'; import { filterURLForDisplay, safeDecodeURI } from '@wordpress/url'; @@ -31,23 +33,12 @@ import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies */ +import { unlock } from '../../lock-unlock'; import { ViewerSlot } from './viewer-slot'; -import useRichUrlData from './use-rich-url-data'; +const { Badge } = unlock( componentsPrivateApis ); -/** - * Filters the title for display. Removes the protocol and www prefix. - * - * @param {string} title The title to be filtered. - * - * @return {string} The filtered title. - */ -function filterTitleForDisplay( title ) { - // Derived from `filterURLForDisplay` in `@wordpress/url`. - return title - .replace( /^[a-z\-.\+]+[0-9]*:(\/\/)?/i, '' ) - .replace( /^www\./i, '' ); -} +import useRichUrlData from './use-rich-url-data'; export default function LinkPreview( { value, @@ -81,9 +72,6 @@ export default function LinkPreview( { ! isEmptyURL && stripHTML( richData?.title || value?.title || displayURL ); - const isUrlRedundant = - ! value?.url || filterTitleForDisplay( displayTitle ) === displayURL; - let icon; if ( richData?.icon ) { @@ -115,7 +103,7 @@ export default function LinkPreview( { 'is-url-title': displayTitle === displayURL, } ) } > - + - - { icon } - + { value?.image ? ( + + + + ) : ( + + { icon } + + ) } { ! isEmptyURL ? ( <> @@ -151,12 +149,28 @@ export default function LinkPreview( { { displayTitle } - { ! isUrlRedundant && ( - - - { displayURL } - - + + + { displayURL } + + + { value?.badges?.length > 0 && ( + + { value.badges.map( + ( badge, index ) => ( + + { badge.label } + + ) + ) } + ) } ) : ( diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss index af88eb8420705b..c8a1f2312140ce 100644 --- a/packages/block-editor/src/components/link-control/style.scss +++ b/packages/block-editor/src/components/link-control/style.scss @@ -192,6 +192,7 @@ $block-editor-link-control-number-of-actions: 1; word-break: break-all; } + .block-editor-link-control__preview-image, .block-editor-link-control__preview-icon { position: relative; flex-shrink: 0; @@ -199,10 +200,10 @@ $block-editor-link-control-number-of-actions: 1; width: $grid-unit-40; height: $grid-unit-40; border-radius: $radius-small; + } - img { - width: $grid-unit-20; // favicons often have a source of 32px - } + .block-editor-link-control__preview-icon img { + width: $grid-unit-20; // favicons often have a source of 32px } &.is-error .block-editor-link-control__preview-icon { diff --git a/packages/block-editor/src/components/link-control/test/index.js b/packages/block-editor/src/components/link-control/test/index.js index 49ac805690d880..f6bc7a60bf4cc8 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -3622,3 +3622,119 @@ async function toggleSettingsDrawer( user ) { await user.click( settingsToggle ); } + +describe( 'Link preview with entity data from navigation blocks', () => { + describe( 'Featured image display', () => { + it( 'should display featured image in link preview when entity provides image', () => { + const linkWithImage = { + url: 'https://example.com/my-page', + title: 'My Test Page', + image: 'https://example.com/featured.jpg', + }; + + render( ); + + const linkPreview = screen.getByRole( 'group', { + name: 'Manage link', + } ); + + // eslint-disable-next-line testing-library/no-node-access + const imagePreview = linkPreview.querySelector( + '.block-editor-link-control__preview-image img' + ); + + expect( imagePreview ).toBeInTheDocument(); + expect( imagePreview ).toHaveAttribute( + 'src', + 'https://example.com/featured.jpg' + ); + } ); + + it( 'should not display featured image section when entity has no image', () => { + const linkWithoutImage = { + url: 'https://example.com/my-page', + title: 'My Test Page', + }; + + render( ); + + const linkPreview = screen.getByRole( 'group', { + name: 'Manage link', + } ); + + // eslint-disable-next-line testing-library/no-node-access + const imagePreview = linkPreview.querySelector( + '.block-editor-link-control__preview-image' + ); + + expect( imagePreview ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'Entity status badges', () => { + it( 'should display badges in link preview when entity provides badges', () => { + const linkWithBadges = { + url: 'https://example.com/my-page', + title: 'My Test Page', + badges: [ + { label: 'Draft', intent: 'warning' }, + { label: 'Page', intent: 'default' }, + ], + }; + + render( ); + + const linkPreview = screen.getByRole( 'group', { + name: 'Manage link', + } ); + + // eslint-disable-next-line testing-library/no-node-access + const badgesContainer = linkPreview.querySelector( + '.block-editor-link-control__preview-badges' + ); + + expect( badgesContainer ).toBeInTheDocument(); + } ); + + it( 'should not display badges section when entity provides no badges', () => { + const linkWithoutBadges = { + url: 'https://example.com/my-page', + title: 'My Test Page', + }; + + render( ); + + const linkPreview = screen.getByRole( 'group', { + name: 'Manage link', + } ); + + // eslint-disable-next-line testing-library/no-node-access + const badgesContainer = linkPreview.querySelector( + '.block-editor-link-control__preview-badges' + ); + + expect( badgesContainer ).not.toBeInTheDocument(); + } ); + + it( 'should not display badges section when entity provides empty badges array', () => { + const linkWithEmptyBadges = { + url: 'https://example.com/my-page', + title: 'My Test Page', + badges: [], + }; + + render( ); + + const linkPreview = screen.getByRole( 'group', { + name: 'Manage link', + } ); + + // eslint-disable-next-line testing-library/no-node-access + const badgesContainer = linkPreview.querySelector( + '.block-editor-link-control__preview-badges' + ); + + expect( badgesContainer ).not.toBeInTheDocument(); + } ); + } ); +} ); diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index 1b2d94ebe99003..81b5b53b59396a 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -167,10 +167,11 @@ export default function NavigationLinkEdit( { const { getBlocks } = useSelect( blockEditorStore ); // URL binding logic - const { hasUrlBinding, isBoundEntityAvailable } = useEntityBinding( { - clientId, - attributes, - } ); + const { hasUrlBinding, isBoundEntityAvailable, entityRecord } = + useEntityBinding( { + clientId, + attributes, + } ); const handleLinkChange = useHandleLinkChange( { clientId, @@ -462,6 +463,11 @@ export default function NavigationLinkEdit( { ref={ linkUIref } clientId={ clientId } link={ attributes } + entity={ { + entityRecord, + hasBinding: hasUrlBinding, + isEntityAvailable: isBoundEntityAvailable, + } } onClose={ () => { setIsLinkOpen( false ); // If there is no link and no binding, remove the auto-inserted block. diff --git a/packages/block-library/src/navigation-link/link-ui/index.js b/packages/block-library/src/navigation-link/link-ui/index.js index dc111919e37281..517dce248abc77 100644 --- a/packages/block-library/src/navigation-link/link-ui/index.js +++ b/packages/block-library/src/navigation-link/link-ui/index.js @@ -27,7 +27,7 @@ import { isURL } from '@wordpress/url'; */ import { LinkUIPageCreator } from './page-creator'; import LinkUIBlockInserter from './block-inserter'; -import { useEntityBinding } from '../shared/use-entity-binding'; +import { useEntityBinding, useLinkPreview } from '../shared'; /** * Given the Link block's type attribute, return the query params to give to @@ -72,6 +72,17 @@ export function getSuggestionsQuery( type, kind ) { function UnforwardedLinkUI( props, ref ) { const { label, url, opensInNewTab, type, kind, id } = props.link; + + const { entityRecord, hasBinding, isEntityAvailable } = props.entity || {}; + + const { image, badges } = useLinkPreview( { + url, + entityRecord, + type, + hasBinding, + isEntityAvailable, + } ); + const { clientId } = props; const postType = type || 'page'; @@ -106,8 +117,10 @@ function UnforwardedLinkUI( props, ref ) { kind, type, id, + image, + badges, } ), - [ label, opensInNewTab, url, kind, type, id ] + [ label, opensInNewTab, url, kind, type, id, image, badges ] ); const handlePageCreated = ( pageLink ) => { diff --git a/packages/block-library/src/navigation-link/shared/controls.js b/packages/block-library/src/navigation-link/shared/controls.js index d703cf6a059567..1ba79ee931b57c 100644 --- a/packages/block-library/src/navigation-link/shared/controls.js +++ b/packages/block-library/src/navigation-link/shared/controls.js @@ -115,38 +115,6 @@ export function Controls( { attributes, setAttributes, clientId } ) { setAttributes, } ); - const linkTitle = - entityRecord?.title?.rendered || - entityRecord?.title || - entityRecord?.name; - - const linkImage = useSelect( - ( select ) => { - // Only fetch for post-type entities with featured media - if ( ! entityRecord?.featured_media ) { - return null; - } - - const { getEntityRecord } = select( coreStore ); - - // Get the media entity to fetch the image URL - const media = getEntityRecord( - 'postType', - 'attachment', - entityRecord.featured_media - ); - - // Return the thumbnail or medium size URL, fallback to source_url - return ( - media?.media_details?.sizes?.thumbnail?.source_url || - media?.media_details?.sizes?.medium?.source_url || - media?.source_url || - null - ); - }, - [ entityRecord?.featured_media ] - ); - const onNavigateToEntityRecord = useSelect( ( select ) => select( blockEditorStore ).getSettings().onNavigateToEntityRecord, @@ -168,10 +136,8 @@ export function Controls( { attributes, setAttributes, clientId } ) { const preview = useLinkPreview( { url, - title: linkTitle, - image: linkImage, + entityRecord, type: attributes.type, - entityStatus: entityRecord?.status, hasBinding: hasUrlBinding, isEntityAvailable: isBoundEntityAvailable, } ); diff --git a/packages/block-library/src/navigation-link/shared/index.js b/packages/block-library/src/navigation-link/shared/index.js index c0b0273057468c..49777b0470789d 100644 --- a/packages/block-library/src/navigation-link/shared/index.js +++ b/packages/block-library/src/navigation-link/shared/index.js @@ -18,3 +18,4 @@ export { InvalidDraftDisplay } from './invalid-draft-display'; export { useEnableLinkStatusValidation } from './use-enable-link-status-validation'; export { useIsDraggingWithin } from './use-is-dragging-within'; export { selectLabelText } from './select-label-text'; +export { useLinkPreview } from './use-link-preview'; diff --git a/packages/block-library/src/navigation-link/shared/test/use-link-preview.test.js b/packages/block-library/src/navigation-link/shared/test/use-link-preview.test.js index 44616a244bc8db..2fbe1796afce39 100644 --- a/packages/block-library/src/navigation-link/shared/test/use-link-preview.test.js +++ b/packages/block-library/src/navigation-link/shared/test/use-link-preview.test.js @@ -1,7 +1,53 @@ +/** + * @jest-environment jsdom + */ + +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + +// Mock useRemoteUrlData from block-editor +const mockUseRemoteUrlData = jest.fn(); + +jest.mock( '@wordpress/block-editor', () => ( { + privateApis: {}, + store: {}, +} ) ); + +// Mock the unlock function to return useRemoteUrlData, isHashLink, and isRelativePath +jest.mock( '../../../lock-unlock', () => ( { + unlock: jest.fn( () => ( { + useRemoteUrlData: ( ...args ) => mockUseRemoteUrlData( ...args ), + isHashLink: ( url ) => url?.startsWith( '#' ), + isRelativePath: ( url ) => + url?.startsWith( '/' ) && ! url?.startsWith( '//' ), + } ) ), +} ) ); + /** * Internal dependencies */ -import { computeDisplayUrl, computeBadges } from '../use-link-preview'; +import { + computeDisplayUrl, + computeBadges, + useLinkPreview, +} from '../use-link-preview'; + +// Mock @wordpress/data +jest.mock( '@wordpress/data', () => ( { + useSelect: jest.fn(), +} ) ); + +// Mock @wordpress/core-data +jest.mock( '@wordpress/core-data', () => ( { + store: {}, +} ) ); describe( 'computeDisplayUrl', () => { describe( 'external links', () => { @@ -250,3 +296,235 @@ it( 'should show "Page" badge for internal custom links', () => { intent: 'default', } ); } ); + +describe( 'useLinkPreview', () => { + beforeEach( () => { + jest.clearAllMocks(); + mockUseRemoteUrlData.mockReturnValue( { richData: null } ); + useSelect.mockReturnValue( null ); + } ); + + describe( 'title computation', () => { + it( 'should use entityRecord.title.rendered when available', () => { + const { result } = renderHook( () => + useLinkPreview( { + url: 'https://example.com', + entityRecord: { title: { rendered: 'Test Page' } }, + type: 'page', + hasBinding: false, + isEntityAvailable: true, + } ) + ); + + expect( result.current.title ).toBe( 'Test Page' ); + } ); + + it( 'should use entityRecord.title string when rendered not available', () => { + const { result } = renderHook( () => + useLinkPreview( { + url: 'https://example.com', + entityRecord: { title: 'Test Category' }, + type: 'category', + hasBinding: false, + isEntityAvailable: true, + } ) + ); + + expect( result.current.title ).toBe( 'Test Category' ); + } ); + + it( 'should use entityRecord.name for taxonomy terms', () => { + const { result } = renderHook( () => + useLinkPreview( { + url: 'https://example.com', + entityRecord: { name: 'Test Tag' }, + type: 'tag', + hasBinding: false, + isEntityAvailable: true, + } ) + ); + + expect( result.current.title ).toBe( 'Test Tag' ); + } ); + + it( 'should use richData title when entity title not available', () => { + mockUseRemoteUrlData.mockReturnValue( { + richData: { title: 'External Site Title' }, + } ); + + const { result } = renderHook( () => + useLinkPreview( { + url: 'https://external.com', + entityRecord: null, + type: null, + hasBinding: false, + isEntityAvailable: false, + } ) + ); + + expect( result.current.title ).toBe( 'External Site Title' ); + } ); + + it( 'should use URL when no title available', () => { + const { result } = renderHook( () => + useLinkPreview( { + url: 'https://example.com/page', + entityRecord: null, + type: null, + hasBinding: false, + isEntityAvailable: false, + } ) + ); + + expect( result.current.title ).toBe( 'https://example.com/page' ); + } ); + + it( 'should return "Add link" when no URL provided', () => { + const { result } = renderHook( () => + useLinkPreview( { + url: '', + entityRecord: null, + type: null, + hasBinding: false, + isEntityAvailable: false, + } ) + ); + + expect( result.current.title ).toBe( 'Add link' ); + } ); + } ); + + describe( 'URL computation', () => { + it( 'should return a URL string', () => { + const { result } = renderHook( () => + useLinkPreview( { + url: 'https://example.com/about', + entityRecord: null, + type: 'page', + hasBinding: false, + isEntityAvailable: true, + } ) + ); + + expect( typeof result.current.url ).toBe( 'string' ); + expect( result.current.url.length ).toBeGreaterThan( 0 ); + } ); + } ); + + describe( 'image computation', () => { + it( 'should fetch featured image when featured_media is available', () => { + const mockMedia = { + media_details: { + sizes: { + thumbnail: { + source_url: 'https://example.com/thumb.jpg', + }, + }, + }, + }; + + useSelect.mockImplementation( ( callback ) => + callback( () => ( { + getEntityRecord: () => mockMedia, + } ) ) + ); + + const { result } = renderHook( () => + useLinkPreview( { + url: 'https://example.com', + entityRecord: { featured_media: 123 }, + type: 'page', + hasBinding: false, + isEntityAvailable: true, + } ) + ); + + expect( result.current.image ).toBe( + 'https://example.com/thumb.jpg' + ); + } ); + + it( 'should return null when no featured_media', () => { + useSelect.mockReturnValue( null ); + + const { result } = renderHook( () => + useLinkPreview( { + url: 'https://example.com', + entityRecord: { title: 'Test' }, + type: 'page', + hasBinding: false, + isEntityAvailable: true, + } ) + ); + + expect( result.current.image ).toBeNull(); + } ); + } ); + + describe( 'badges computation', () => { + it( 'should show "No link selected" badge when no URL', () => { + const { result } = renderHook( () => + useLinkPreview( { + url: '', + entityRecord: null, + type: null, + hasBinding: false, + isEntityAvailable: false, + } ) + ); + + expect( result.current.badges ).toContainEqual( { + label: 'No link selected', + intent: 'error', + } ); + } ); + + it( 'should show entity type badge when type is provided', () => { + const { result } = renderHook( () => + useLinkPreview( { + url: 'https://example.com/page', + entityRecord: { title: 'Test' }, + type: 'page', + hasBinding: false, + isEntityAvailable: true, + } ) + ); + + // Should have at least one badge + expect( result.current.badges.length ).toBeGreaterThan( 0 ); + } ); + + it( 'should show "Missing" badge when entity is not available', () => { + const { result } = renderHook( () => + useLinkPreview( { + url: 'https://example.com', + entityRecord: null, + type: 'page', + hasBinding: true, + isEntityAvailable: false, + } ) + ); + + expect( result.current.badges ).toContainEqual( { + label: 'Missing page', + intent: 'error', + } ); + } ); + + it( 'should include badges when entity has status', () => { + const { result } = renderHook( () => + useLinkPreview( { + url: 'https://example.com/post', + entityRecord: { status: 'draft', title: 'Test' }, + type: 'post', + hasBinding: false, + isEntityAvailable: true, + } ) + ); + + // Just verify badges array exists and has items + expect( Array.isArray( result.current.badges ) ).toBe( true ); + expect( result.current.badges.length ).toBeGreaterThan( 0 ); + } ); + } ); +} ); diff --git a/packages/block-library/src/navigation-link/shared/use-link-preview.js b/packages/block-library/src/navigation-link/shared/use-link-preview.js index 8fd2bda2da0108..b81096ee5ec47e 100644 --- a/packages/block-library/src/navigation-link/shared/use-link-preview.js +++ b/packages/block-library/src/navigation-link/shared/use-link-preview.js @@ -161,20 +161,16 @@ export function computeBadges( { * * @param {Object} options - Options object * @param {string} options.url - Link URL - * @param {string} options.title - Link title (from entity or rich data) - * @param {string} options.image - Link image URL * @param {string} options.type - Entity type (page, post, etc.) - * @param {string} options.entityStatus - Entity status (publish, draft, etc.) + * @param {Object} options.entityRecord - Entity record * @param {boolean} options.hasBinding - Whether link has entity binding * @param {boolean} options.isEntityAvailable - Whether bound entity exists * @return {Object} Preview data object with title, url, image, and badges */ export function useLinkPreview( { url, - title, - image, + entityRecord, type, - entityStatus, hasBinding, isEntityAvailable, } ) { @@ -187,6 +183,11 @@ export function useLinkPreview( { return siteEntity?.url; }, [] ); + const title = + entityRecord?.title?.rendered || + entityRecord?.title || + entityRecord?.name; + // Fetch rich URL data if we don't have a title. Internal links should have passed a title. const { richData } = useRemoteUrlData( title ? null : url ); @@ -196,12 +197,39 @@ export function useLinkPreview( { siteUrl, } ); + const image = useSelect( + ( select ) => { + // Only fetch for post-type entities with featured media + if ( ! entityRecord?.featured_media ) { + return null; + } + + const { getEntityRecord } = select( coreDataStore ); + + // Get the media entity to fetch the image URL + const media = getEntityRecord( + 'postType', + 'attachment', + entityRecord.featured_media + ); + + // Return the thumbnail or medium size URL, fallback to source_url + return ( + media?.media_details?.sizes?.thumbnail?.source_url || + media?.media_details?.sizes?.medium?.source_url || + media?.source_url || + null + ); + }, + [ entityRecord?.featured_media ] + ); + // Compute badges const badges = computeBadges( { url, type, isExternal, - entityStatus, + entityStatus: entityRecord?.status, hasBinding, isEntityAvailable, } ); diff --git a/packages/block-library/src/navigation-submenu/edit.js b/packages/block-library/src/navigation-submenu/edit.js index 9e1dcce14b63d4..0e461a90dfc18e 100644 --- a/packages/block-library/src/navigation-submenu/edit.js +++ b/packages/block-library/src/navigation-submenu/edit.js @@ -92,7 +92,13 @@ export default function NavigationSubmenuEdit( { blockEditingMode !== 'default' ? true : submenuVisibility === 'click'; // URL binding logic - const { clearBinding, createBinding } = useEntityBinding( { + const { + clearBinding, + createBinding, + hasUrlBinding, + isBoundEntityAvailable, + entityRecord, + } = useEntityBinding( { clientId, attributes, } ); @@ -378,6 +384,11 @@ export default function NavigationSubmenuEdit( { { setIsLinkOpen( false ); } }