diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index 8903aac594c763..d44e0870093a6c 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -133,6 +133,82 @@ function getSuggestionsQuery( type, kind ) { } } +/** + * @typedef {'post-type'|'custom'|'taxonomy'|'post-type-archive'} WPNavigationLinkKind + */ + +/** + * Navigation Link Block Attributes + * + * @typedef {Object} WPNavigationLinkBlockAttributes + * + * @property {string} [label] Link text. + * @property {WPNavigationLinkKind} [kind] Kind is used to differentiate between term and post ids to check post draft status. + * @property {string} [type] The type such as post, page, tag, category and other custom types. + * @property {string} [rel] The relationship of the linked URL. + * @property {number} [id] A post or term id. + * @property {boolean} [opensInNewTab] Sets link target to _blank when true. + * @property {string} [url] Link href. + * @property {string} [title] Link title attribute. + */ + +/** + * Link Control onChange handler that updates block attributes when a setting is changed. + * + * @param {Object} updatedValue New block attributes to update. + * @param {Function} setAttributes Block attribute update function. + * @param {WPNavigationLinkBlockAttributes} blockAttributes Current block attributes. + * + */ +export const updateNavigationLinkBlockAttributes = ( + updatedValue = {}, + setAttributes, + blockAttributes = {} +) => { + const { + label: originalLabel = '', + kind: originalKind = '', + type: originalType = '', + } = blockAttributes; + const { + title = '', + url = '', + opensInNewTab, + id, + kind: newKind = originalKind, + type: newType = originalType, + } = updatedValue; + + const normalizedTitle = title.replace( /http(s?):\/\//gi, '' ); + const normalizedURL = url.replace( /http(s?):\/\//gi, '' ); + const escapeTitle = + title !== '' && + normalizedTitle !== normalizedURL && + originalLabel !== title; + const label = escapeTitle + ? escape( title ) + : originalLabel || escape( normalizedURL ); + + // In https://github.com/WordPress/gutenberg/pull/24670 we decided to use "tag" in favor of "post_tag" + const type = newType === 'post_tag' ? 'tag' : newType.replace( '-', '_' ); + + const isBuiltInType = + [ 'post', 'page', 'tag', 'category' ].indexOf( type ) > -1; + + const isCustomLink = + ( ! newKind && ! isBuiltInType ) || newKind === 'custom'; + const kind = isCustomLink ? 'custom' : newKind; + + setAttributes( { + ...( url && { url: encodeURI( url ) } ), + ...( label && { label } ), + ...( undefined !== opensInNewTab && { opensInNewTab } ), + ...( id && Number.isInteger( id ) && { id } ), + ...( kind && { kind } ), + ...( type && type !== 'URL' && { type } ), + } ); +}; + export default function NavigationLinkEdit( { attributes, isSelected, @@ -519,55 +595,13 @@ export default function NavigationLinkEdit( { type, kind ) } - onChange={ ( { - title: newTitle = '', - url: newURL = '', - opensInNewTab: newOpensInNewTab, - id: newId, - kind: newKind = '', - type: newType = '', - } = {} ) => { - setAttributes( { - url: encodeURI( newURL ), - label: ( () => { - const normalizedTitle = newTitle.replace( - /http(s?):\/\//gi, - '' - ); - const normalizedURL = newURL.replace( - /http(s?):\/\//gi, - '' - ); - if ( - newTitle !== '' && - normalizedTitle !== - normalizedURL && - label !== newTitle - ) { - return escape( newTitle ); - } else if ( label ) { - return label; - } - // If there's no label, add the URL. - return escape( normalizedURL ); - } )(), - opensInNewTab: newOpensInNewTab, - // `id` represents the DB ID of the entity which this link represents (eg: Post ID). - // Therefore we must not inadvertently set it to `undefined` if the `onChange` is called with no `id` value. - // This is possible when a setting changes such as the `opensInNewTab`. - ...( newId && { - id: newId, - } ), - ...( newKind && { - kind: newKind, - } ), - ...( newType && - newType !== 'URL' && - newType !== 'post-format' && { - type: newType, - } ), - } ); - } } + onChange={ ( updatedValue ) => + updateNavigationLinkBlockAttributes( + updatedValue, + setAttributes, + attributes + ) + } /> ) } diff --git a/packages/block-library/src/navigation-link/test/edit.js b/packages/block-library/src/navigation-link/test/edit.js new file mode 100644 index 00000000000000..71262d3e8c7f40 --- /dev/null +++ b/packages/block-library/src/navigation-link/test/edit.js @@ -0,0 +1,511 @@ +/** + * Internal dependencies + */ +import { updateNavigationLinkBlockAttributes } from '../edit'; + +describe( 'edit', () => { + describe( 'updateNavigationLinkBlockAttributes', () => { + // data shapes are linked to fetchLinkSuggestions from + // core-data/src/fetch/__experimental-fetch-link-suggestions.js + it( 'can update a post link', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + opensInNewTab: false, + id: 1337, + url: 'https://wordpress.local/menu-test/', + kind: 'post-type', + title: 'Menu Test', + type: 'post', + }; + + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 1337, + label: 'Menu Test', + opensInNewTab: false, + kind: 'post-type', + type: 'post', + url: 'https://wordpress.local/menu-test/', + } ); + } ); + + it( 'can update a page link', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 2, + kind: 'post-type', + opensInNewTab: false, + title: 'Sample Page', + type: 'page', + url: 'http://wordpress.local/sample-page/', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 2, + kind: 'post-type', + label: 'Sample Page', + opensInNewTab: false, + type: 'page', + url: 'http://wordpress.local/sample-page/', + } ); + } ); + + it( 'can update a tag link', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 15, + kind: 'taxonomy', + opensInNewTab: false, + title: 'bar', + type: 'post_tag', + url: 'http://wordpress.local/tag/bar/', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 15, + kind: 'taxonomy', + opensInNewTab: false, + label: 'bar', + type: 'tag', + url: 'http://wordpress.local/tag/bar/', + } ); + } ); + + it( 'can update a category link', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 9, + kind: 'taxonomy', + opensInNewTab: false, + title: 'Cats', + type: 'category', + url: 'http://wordpress.local/category/cats/', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 9, + kind: 'taxonomy', + opensInNewTab: false, + label: 'Cats', + type: 'category', + url: 'http://wordpress.local/category/cats/', + } ); + } ); + + it( 'can update a custom post type link', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 131, + kind: 'post-type', + opensInNewTab: false, + title: 'Fall', + type: 'portfolio', + url: 'http://wordpress.local/portfolio/fall/', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 131, + kind: 'post-type', + opensInNewTab: false, + label: 'Fall', + type: 'portfolio', + url: 'http://wordpress.local/portfolio/fall/', + } ); + } ); + + it( 'can update a custom tag link', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 4, + kind: 'taxonomy', + opensInNewTab: false, + title: 'Portfolio Tag', + type: 'portfolio_tag', + url: 'http://wordpress.local/portfolio_tag/PortfolioTag/', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 4, + kind: 'taxonomy', + opensInNewTab: false, + label: 'Portfolio Tag', + type: 'portfolio_tag', + url: 'http://wordpress.local/portfolio_tag/PortfolioTag/', + } ); + } ); + + it( 'can update a custom category link', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 2, + kind: 'taxonomy', + opensInNewTab: false, + title: 'Portfolio Category', + type: 'portfolio_category', + url: + 'http://wordpress.local/portfolio_category/Portfolio-category/', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 2, + kind: 'taxonomy', + opensInNewTab: false, + label: 'Portfolio Category', + type: 'portfolio_category', + url: + 'http://wordpress.local/portfolio_category/Portfolio-category/', + } ); + } ); + + it( 'can update a post format and ignores id slug', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 'video', + kind: 'taxonomy', + opensInNewTab: false, + title: 'Video', + type: 'post-format', + url: 'http://wordpress.local/type/video/', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + // post_format returns a slug ID value from the Search API + // we do not persist this ID since we expect this value to be a post or term ID + expect( setAttributes ).toHaveBeenCalledWith( { + kind: 'taxonomy', + opensInNewTab: false, + label: 'Video', + type: 'post_format', + url: 'http://wordpress.local/type/video/', + } ); + } ); + + describe( 'various link protocols save as custom links', () => { + it( 'when typing a url, but not selecting a search suggestion', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + opensInNewTab: false, + url: 'www.wordpress.org', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + opensInNewTab: false, + url: 'www.wordpress.org', + label: 'www.wordpress.org', + kind: 'custom', + } ); + } ); + + it( 'url', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 'www.wordpress.org', + opensInNewTab: false, + title: 'www.wordpress.org', + type: 'URL', + url: 'http://www.wordpress.org', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + opensInNewTab: false, + label: 'www.wordpress.org', + kind: 'custom', + url: 'http://www.wordpress.org', + } ); + } ); + + it( 'email', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 'mailto:foo@example.com', + opensInNewTab: false, + title: 'mailto:foo@example.com', + type: 'mailto', + url: 'mailto:foo@example.com', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + opensInNewTab: false, + label: 'mailto:foo@example.com', + kind: 'custom', + url: 'mailto:foo@example.com', + type: 'mailto', + } ); + } ); + + it( 'anchor links (internal links)', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: '#foo', + opensInNewTab: false, + title: '#foo', + type: 'internal', + url: '#foo', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + opensInNewTab: false, + label: '#foo', + kind: 'custom', + url: '#foo', + type: 'internal', + } ); + } ); + + it( 'telephone', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 'tel:5555555', + opensInNewTab: false, + title: 'tel:5555555', + type: 'tel', + url: 'tel:5555555', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + opensInNewTab: false, + label: 'tel:5555555', + kind: 'custom', + url: 'tel:5555555', + type: 'tel', + } ); + } ); + } ); + + describe( 'link label', () => { + // https://github.com/WordPress/gutenberg/pull/19461 + it( 'sets the url as a label if title is not provided', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 'www.wordpress.org/foo bar', + opensInNewTab: false, + title: '', + type: 'URL', + url: 'https://www.wordpress.org', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + opensInNewTab: false, + label: 'www.wordpress.org', + kind: 'custom', + url: 'https://www.wordpress.org', + } ); + } ); + it( 'does not replace label when editing url without protocol', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 'www.wordpress.org', + opensInNewTab: false, + title: 'Custom Title', + type: 'URL', + url: 'wordpress.org', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + opensInNewTab: false, + label: 'Custom Title', + kind: 'custom', + url: 'wordpress.org', + } ); + } ); + it( 'does not replace label when editing url with protocol', () => { + const setAttributes = jest.fn(); + const linkSuggestion = { + id: 'www.wordpress.org', + opensInNewTab: false, + title: 'Custom Title', + type: 'URL', + url: 'https://wordpress.org', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + opensInNewTab: false, + label: 'Custom Title', + kind: 'custom', + 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(); + const linkSuggestion = { + id: 'http://wordpress.org/?s=', + opensInNewTab: false, + title: 'Custom Title', + type: 'URL', + url: 'http://wordpress.org/?s=<>', + }; + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes + ); + expect( setAttributes ).toHaveBeenCalledWith( { + opensInNewTab: false, + label: 'Custom Title', + kind: 'custom', + url: 'http://wordpress.org/?s=%3C%3E', + } ); + } ); + } ); + + describe( 'does not overwrite props when only some props are passed', () => { + it( 'id is retained after toggling opensInNewTab', () => { + const mockState = {}; + const setAttributes = jest.fn( ( attr ) => + Object.assign( mockState, attr ) + ); + const linkSuggestion = { + opensInNewTab: false, + id: 1337, + url: 'https://wordpress.local/menu-test/', + kind: 'post-type', + title: 'Menu Test', + type: 'post', + }; + + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes, + mockState + ); + expect( mockState ).toEqual( { + id: 1337, + label: 'Menu Test', + opensInNewTab: false, + kind: 'post-type', + type: 'post', + url: 'https://wordpress.local/menu-test/', + } ); + //click on the existing link control, and toggle opens new tab + updateNavigationLinkBlockAttributes( + { + url: 'https://wordpress.local/menu-test/', + opensInNewTab: true, + }, + setAttributes, + mockState + ); + expect( mockState ).toEqual( { + id: 1337, + label: 'Menu Test', + opensInNewTab: true, + kind: 'post-type', + type: 'post', + url: 'https://wordpress.local/menu-test/', + } ); + } ); + it( 'id is retained after editing url', () => { + const mockState = {}; + const setAttributes = jest.fn( ( attr ) => + Object.assign( mockState, attr ) + ); + const linkSuggestion = { + opensInNewTab: false, + id: 1337, + url: 'https://wordpress.local/menu-test/', + kind: 'post-type', + title: 'Menu Test', + type: 'post', + }; + + updateNavigationLinkBlockAttributes( + linkSuggestion, + setAttributes, + mockState + ); + expect( mockState ).toEqual( { + id: 1337, + label: 'Menu Test', + opensInNewTab: false, + kind: 'post-type', + type: 'post', + url: 'https://wordpress.local/menu-test/', + } ); + //click on the existing link control, and toggle opens new tab + updateNavigationLinkBlockAttributes( + { + url: 'https://wordpress.local/foo/', + opensInNewTab: false, + }, + setAttributes, + mockState + ); + expect( mockState ).toEqual( { + id: 1337, + label: 'Menu Test', + opensInNewTab: false, + kind: 'post-type', + type: 'post', + url: 'https://wordpress.local/foo/', + } ); + } ); + } ); + } ); +} ); diff --git a/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.js b/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.js index 10252f736bb2ff..625da549326527 100644 --- a/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.js +++ b/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.js @@ -140,7 +140,16 @@ const fetchLinkSuggestions = async ( type: 'post-format', subtype, } ), - } ).catch( () => [] ) + } ) + .then( ( results ) => { + return results.map( ( result ) => { + return { + ...result, + meta: { kind: 'taxonomy', subtype }, + }; + } ); + } ) + .catch( () => [] ) ); } diff --git a/packages/core-data/src/fetch/test/__experimental-fetch-link-suggestions.js b/packages/core-data/src/fetch/test/__experimental-fetch-link-suggestions.js index 5270b43cfc082c..b4a2a4ec0d3fed 100644 --- a/packages/core-data/src/fetch/test/__experimental-fetch-link-suggestions.js +++ b/packages/core-data/src/fetch/test/__experimental-fetch-link-suggestions.js @@ -40,12 +40,14 @@ jest.mock( '@wordpress/api-fetch', () => title: 'Gallery', url: 'http://wordpress.local/type/gallery/', type: 'post-format', + kind: 'taxonomy', }, { id: 'quote', title: 'Quote', url: 'http://wordpress.local/type/quote/', type: 'post-format', + kind: 'taxonomy', }, ] ); case '/wp/v2/search?search=&per_page=3&type=post&subtype=page': @@ -131,12 +133,14 @@ describe( 'fetchLinkSuggestions', () => { title: 'Gallery', url: 'http://wordpress.local/type/gallery/', type: 'post-format', + kind: 'taxonomy', }, { id: 'quote', title: 'Quote', url: 'http://wordpress.local/type/quote/', type: 'post-format', + kind: 'taxonomy', }, ] ) ); @@ -179,12 +183,14 @@ describe( 'fetchLinkSuggestions', () => { title: 'Gallery', url: 'http://wordpress.local/type/gallery/', type: 'post-format', + kind: 'taxonomy', }, { id: 'quote', title: 'Quote', url: 'http://wordpress.local/type/quote/', type: 'post-format', + kind: 'taxonomy', }, ] ) ); diff --git a/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap b/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap index 52204753521c4a..25bae5dff448ea 100644 --- a/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap +++ b/packages/e2e-tests/specs/experiments/blocks/__snapshots__/navigation.test.js.snap @@ -46,7 +46,7 @@ exports[`Navigation Creating from existing Pages allows a navigation block to be exports[`Navigation allows an empty navigation block to be created and manually populated using a mixture of internal and external links 1`] = ` " - + "