diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index 659f7ae115ec59..34367cd56ad9fc 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -2,7 +2,7 @@ * External dependencies */ import classnames from 'classnames'; -import { escape } from 'lodash'; +import { escape, unescape } from 'lodash'; /** * WordPress dependencies @@ -43,6 +43,7 @@ import { import { placeCaretAtHorizontalEdge } from '@wordpress/dom'; import { link as linkIcon, addSubmenu } from '@wordpress/icons'; import { store as coreStore } from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies @@ -239,6 +240,15 @@ export const updateNavigationLinkBlockAttributes = ( normalizedTitle !== normalizedURL && originalLabel !== title; + // Unfortunately this causes the escaping model to be inverted. + // The escaped content is stored in the block attributes (and ultimately in the database), + // and then the raw data is "recovered" when outputting into the DOM. + // It would be preferable to store the **raw** data in the block attributes and escape it in JS. + // Why? Because there isn't one way to escape data. Depending on the context, you need to do + // different transforms. It doesn't make sense to me to choose one of them for the purposes of storage. + // See also: + // - https://github.com/WordPress/gutenberg/pull/41063 + // - https://github.com/WordPress/gutenberg/pull/18617. const label = escapeTitle ? escape( title ) : originalLabel || escape( normalizedURL ); @@ -606,7 +616,17 @@ export default function NavigationLinkEdit( { return { id: page.id, type: postType, - title: page.title.rendered, + // Make `title` property consistent with that in `fetchLinkSuggestions` where the `rendered` title (containing HTML entities) + // is also being decoded. By being consistent in both locations we avoid having to branch in the rendering output code. + // Ideally in the future we will update both APIs to utilise the "raw" form of the title which is better suited to edit contexts. + // e.g. + // - title.raw = "Yes & No" + // - title.rendered = "Yes & No" + // - decodeEntities( title.rendered ) = "Yes & No" + // See: + // - https://github.com/WordPress/gutenberg/pull/41063 + // - https://github.com/WordPress/gutenberg/blob/a1e1fdc0e6278457e9f4fc0b31ac6d2095f5450b/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.js#L212-L218 + title: decodeEntities( page.title.rendered ), url: page.link, kind: 'post-type', }; @@ -795,10 +815,20 @@ export default function NavigationLinkEdit( { text={ tooltipText } > <> - + { - /* Trim to avoid trailing white space when the placeholder text is not present */ - `${ label } ${ placeholderText }`.trim() + // Some attributes are stored in an escaped form. It's a legacy issue. + // Ideally they would be stored in a raw, unescaped form. + // Unescape is used here to "recover" the escaped characters + // so they display without encoding. + // See `updateNavigationLinkBlockAttributes` for more details. + `${ unescape( + label + ) } ${ placeholderText }`.trim() } diff --git a/packages/block-library/src/navigation-link/test/edit.js b/packages/block-library/src/navigation-link/test/edit.js index cdec00828329e4..1824447849ee73 100644 --- a/packages/block-library/src/navigation-link/test/edit.js +++ b/packages/block-library/src/navigation-link/test/edit.js @@ -372,26 +372,6 @@ describe( 'edit', () => { url: 'https://wordpress.org', } ); } ); - // https://github.com/WordPress/gutenberg/pull/18617 - it( 'label is javascript escaped', () => { - const setAttributes = jest.fn(); - const linkSuggestion = { - opensInNewTab: false, - title: '', - type: 'URL', - url: 'https://wordpress.local?p=1', - }; - updateNavigationLinkBlockAttributes( - linkSuggestion, - setAttributes - ); - expect( setAttributes ).toHaveBeenCalledWith( { - opensInNewTab: false, - label: '<Navigation />', - kind: 'custom', - url: 'https://wordpress.local?p=1', - } ); - } ); // https://github.com/WordPress/gutenberg/pull/19679 it( 'url when escaped is still an actual link', () => { const setAttributes = jest.fn(); diff --git a/packages/e2e-tests/specs/editor/blocks/navigation.test.js b/packages/e2e-tests/specs/editor/blocks/navigation.test.js index 3b859be79dfa29..df835bcbc01378 100644 --- a/packages/e2e-tests/specs/editor/blocks/navigation.test.js +++ b/packages/e2e-tests/specs/editor/blocks/navigation.test.js @@ -807,6 +807,58 @@ describe( 'Navigation', () => { ); } ); + it( 'correctly decodes special characters in the created Page title for display', async () => { + await createNewPost(); + await insertBlock( 'Navigation' ); + const startEmptyButton = await page.waitForXPath( START_EMPTY_XPATH ); + await startEmptyButton.click(); + const appender = await page.waitForSelector( + '.wp-block-navigation .block-list-appender' + ); + await appender.click(); + + // Wait for URL input to be focused + // Insert name for the new page. + const pageTitle = 'This & That & Some < other > chars'; + const input = await page.waitForSelector( + 'input.block-editor-url-input__input:focus' + ); + await input.type( pageTitle ); + + // When creating a page, the URLControl makes a request to the + // url-details endpoint to fetch information about the page. + // Because the draft is inaccessible publicly, this request + // returns a 404 response. Wait for the response and expect + // the error to have occurred. + const createPageButton = await page.waitForSelector( + '.block-editor-link-control__search-create' + ); + const responsePromise = page.waitForResponse( + ( response ) => + response.url().includes( 'url-details' ) && + response.status() === 404 + ); + const createPagePromise = createPageButton.click(); + await Promise.all( [ responsePromise, createPagePromise ] ); + + await waitForBlock( 'Navigation' ); + + const innerLinkBlock = await waitForBlock( 'Custom Link' ); + + const linkText = await innerLinkBlock.$eval( + '[aria-label="Navigation link text"]', + ( element ) => { + return element.innerText; + } + ); + + expect( linkText ).toContain( pageTitle ); + + expect( console ).toHaveErroredWith( + 'Failed to load resource: the server responded with a status of 404 (Not Found)' + ); + } ); + it( 'renders buttons for the submenu opener elements when the block is set to open on click instead of hover', async () => { await createClassicMenu( { name: 'Test Menu 2' }, menuItemsFixture ); await createNewPost();