@wordpress/block-editor
Version:
1,790 lines (1,428 loc) • 71.7 kB
JavaScript
/**
* External dependencies
*/
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import LinkControl from '../';
import {
fauxEntitySuggestions,
fetchFauxEntitySuggestions,
uniqueId,
} from './fixtures';
const mockFetchSearchSuggestions = jest.fn();
/**
* The call to the real method `fetchRichUrlData` is wrapped in a promise in order to make it cancellable.
* Therefore if we pass any value as the mock of `fetchRichUrlData` then ALL of the tests will require
* addition code to handle the async nature of `fetchRichUrlData`. This is unnecessary. Instead we default
* to an undefined value which will ensure that the code under test does not call `fetchRichUrlData`. Only
* when we are testing the "rich previews" to we update this value with a true mock.
*/
let mockFetchRichUrlData;
jest.mock( '@wordpress/data/src/components/use-select', () => {
// This allows us to tweak the returned value on each test.
const mock = jest.fn();
return mock;
} );
useSelect.mockImplementation( () => ( {
fetchSearchSuggestions: mockFetchSearchSuggestions,
fetchRichUrlData: mockFetchRichUrlData,
} ) );
jest.mock( '@wordpress/data/src/components/use-dispatch', () => ( {
useDispatch: () => ( { saveEntityRecords: jest.fn() } ),
} ) );
jest.mock( '@wordpress/compose', () => ( {
...jest.requireActual( '@wordpress/compose' ),
useReducedMotion: jest.fn( () => true ),
} ) );
beforeEach( () => {
// Setup a DOM element as a render target.
mockFetchSearchSuggestions.mockImplementation( fetchFauxEntitySuggestions );
} );
afterEach( () => {
// Cleanup on exiting.
mockFetchSearchSuggestions.mockReset();
mockFetchRichUrlData?.mockReset(); // Conditionally reset as it may NOT be a mock.
} );
/**
* Workaround to trigger an arrow up keypress event.
*
* @todo Remove this workaround in favor of userEvent.keyboard() or userEvent.type().
*
* For some reason, this doesn't work:
*
* ```
* await user.keyboard( '[ArrowDown]' );
* ```
*
* because the event sent has a `keyCode` of `0`.
*
* @param {Element} element Element to trigger the event on.
*/
function triggerArrowUp( element ) {
fireEvent.keyDown( element, {
key: 'ArrowUp',
keyCode: 38,
} );
}
/**
* Workaround to trigger an arrow down keypress event.
*
* @todo Remove this workaround in favor of userEvent.keyboard() or userEvent.type().
*
* For some reason, this doesn't work:
*
* ```
* await user.keyboard( '[ArrowDown]' );
* ```
*
* because the event sent has a `keyCode` of `0`.
*
* @param {Element} element Element to trigger the event on.
*/
function triggerArrowDown( element ) {
fireEvent.keyDown( element, {
key: 'ArrowDown',
keyCode: 40,
} );
}
/**
* Workaround to trigger an Enter keypress event.
*
* @todo Remove this workaround in favor of userEvent.keyboard() or userEvent.type().
*
* For some reason, this doesn't work:
*
* ```
* await user.keyboard( '[Enter]' );
* ```
*
* because the event sent has a `keyCode` of `0`.
*
* @param {Element} element Element to trigger the event on.
*/
function triggerEnter( element ) {
fireEvent.keyDown( element, {
key: 'Enter',
keyCode: 13,
} );
}
describe( 'Basic rendering', () => {
it( 'should render', () => {
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
expect( searchInput ).toBeVisible();
} );
it( 'should have aria-owns attribute to follow the ARIA 1.0 pattern', () => {
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
expect( searchInput ).toBeVisible();
// Make sure we use the ARIA 1.0 pattern with aria-owns.
// See https://github.com/WordPress/gutenberg/issues/47147
expect( searchInput ).not.toHaveAttribute( 'aria-controls' );
expect( searchInput ).toHaveAttribute( 'aria-owns' );
} );
it( 'should have aria-selected attribute only on the highlighted item', async () => {
const user = userEvent.setup();
let resolver;
mockFetchSearchSuggestions.mockImplementation(
() =>
new Promise( ( resolve ) => {
resolver = resolve;
} )
);
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, 'Hello' );
// Wait for the spinner SVG icon to be rendered.
expect( await screen.findByRole( 'presentation' ) ).toBeVisible();
// Check the suggestions list is not rendered yet.
expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
// Make the search suggestions fetch return a response.
resolver( fauxEntitySuggestions );
const resultsList = await screen.findByRole( 'listbox', {
name: 'Search results for "Hello"',
} );
// Check the suggestions list is rendered.
expect( resultsList ).toBeVisible();
// Check the spinner SVG icon is not rendered any longer.
expect( screen.queryByRole( 'presentation' ) ).not.toBeInTheDocument();
const searchResultElements =
within( resultsList ).getAllByRole( 'option' );
expect( searchResultElements ).toHaveLength(
fauxEntitySuggestions.length
);
// Step down into the search results, highlighting the first result item.
triggerArrowDown( searchInput );
const firstSearchSuggestion = searchResultElements[ 0 ];
const secondSearchSuggestion = searchResultElements[ 1 ];
let selectedSearchResultElement = screen.getByRole( 'option', {
selected: true,
} );
// We should have highlighted the first item using the keyboard.
expect( selectedSearchResultElement ).toEqual( firstSearchSuggestion );
// Check the aria-selected attribute is set only on the highlighted item.
expect( firstSearchSuggestion ).toHaveAttribute(
'aria-selected',
'true'
);
// Check the aria-selected attribute is omitted on the non-highlighted items.
expect( secondSearchSuggestion ).not.toHaveAttribute( 'aria-selected' );
// Step down into the search results, highlighting the second result item.
triggerArrowDown( searchInput );
selectedSearchResultElement = screen.getByRole( 'option', {
selected: true,
} );
// We should have highlighted the first item using the keyboard.
expect( selectedSearchResultElement ).toEqual( secondSearchSuggestion );
// Check the aria-selected attribute is omitted on non-highlighted items.
expect( firstSearchSuggestion ).not.toHaveAttribute( 'aria-selected' );
// Check the aria-selected attribute is set only on the highlighted item.
expect( secondSearchSuggestion ).toHaveAttribute(
'aria-selected',
'true'
);
// Step up into the search results, highlighting the first result item.
triggerArrowUp( searchInput );
selectedSearchResultElement = screen.getByRole( 'option', {
selected: true,
} );
// We should be back to highlighting the first search result again.
expect( selectedSearchResultElement ).toEqual( firstSearchSuggestion );
// Check the aria-selected attribute is set only on the highlighted item.
expect( firstSearchSuggestion ).toHaveAttribute(
'aria-selected',
'true'
);
// Check the aria-selected attribute is omitted on non-highlighted items.
expect( secondSearchSuggestion ).not.toHaveAttribute( 'aria-selected' );
} );
it( 'should not render protocol in links', async () => {
const user = userEvent.setup();
mockFetchSearchSuggestions.mockImplementation( () =>
Promise.resolve( [
{
id: uniqueId(),
title: 'Hello Page',
type: 'Page',
info: '2 days ago',
url: `http://example.com/?p=${ uniqueId() }`,
},
{
id: uniqueId(),
title: 'Hello Post',
type: 'Post',
info: '19 days ago',
url: `https://example.com/${ uniqueId() }`,
},
] )
);
const searchTerm = 'Hello';
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchTerm );
expect( screen.queryByText( '://' ) ).not.toBeInTheDocument();
} );
describe( 'forceIsEditingLink', () => {
it( 'undefined', () => {
render( <LinkControl value={ { url: 'https://example.com' } } /> );
expect(
screen.queryByRole( 'combobox', { name: 'Link' } )
).not.toBeInTheDocument();
} );
it( 'true', () => {
render(
<LinkControl
value={ { url: 'https://example.com' } }
forceIsEditingLink
/>
);
expect(
screen.getByRole( 'combobox', { name: 'Link' } )
).toBeVisible();
} );
it( 'false', async () => {
const user = userEvent.setup();
const { rerender } = render(
<LinkControl value={ { url: 'https://example.com' } } />
);
// Click the "Edit" button to trigger into the editing mode.
const editButton = screen.queryByRole( 'button', {
name: 'Edit link',
} );
await user.click( editButton );
expect(
screen.getByRole( 'combobox', { name: 'Link' } )
).toBeVisible();
// If passed `forceIsEditingLink` of `false` while editing, should
// forcefully reset to the preview state.
rerender(
<LinkControl
value={ { url: 'https://example.com' } }
forceIsEditingLink={ false }
/>
);
expect(
screen.queryByRole( 'combobox', { name: 'Link' } )
).not.toBeInTheDocument();
} );
it( 'should display human friendly error message if value URL prop is empty when component is forced into no-editing (preview) mode', async () => {
// Why do we need this test?
// Occasionally `forceIsEditingLink` is set explicitly to `false` which causes the Link UI to render
// it's preview even if the `value` has no URL.
// for an example of this see the usage in the following file whereby forceIsEditingLink is used to start/stop editing mode:
// https://github.com/WordPress/gutenberg/blob/fa5728771df7cdc86369f7157d6aa763649937a7/packages/format-library/src/link/inline.js#L151.
// see also: https://github.com/WordPress/gutenberg/issues/17972.
const valueWithEmptyURL = {
url: '',
id: 123,
type: 'post',
};
render(
<LinkControl
value={ valueWithEmptyURL }
forceIsEditingLink={ false }
/>
);
const linkPreview = screen.getByRole( 'group', {
name: 'Manage link',
} );
const isPreviewError = linkPreview.classList.contains( 'is-error' );
expect( isPreviewError ).toBe( true );
expect( screen.queryByText( 'Link is empty' ) ).toBeVisible();
} );
} );
describe( 'Unlinking', () => {
it( 'should not show "Unlink" button if no onRemove handler is provided', () => {
render( <LinkControl value={ { url: 'https://example.com' } } /> );
const unLinkButton = screen.queryByRole( 'button', {
name: 'Remove link',
} );
expect( unLinkButton ).not.toBeInTheDocument();
} );
it( 'should show "Unlink" button if a onRemove handler is provided', async () => {
const user = userEvent.setup();
const mockOnRemove = jest.fn();
render(
<LinkControl
value={ { url: 'https://example.com' } }
onRemove={ mockOnRemove }
/>
);
const unLinkButton = screen.queryByRole( 'button', {
name: 'Remove link',
} );
expect( unLinkButton ).toBeVisible();
await user.click( unLinkButton );
expect( mockOnRemove ).toHaveBeenCalled();
} );
it( 'should revert to "editing" mode when onRemove is triggered', async () => {
const user = userEvent.setup();
const mockOnRemove = jest.fn();
render(
<LinkControl
value={ { url: 'https://example.com' } }
onRemove={ mockOnRemove }
/>
);
const unLinkButton = screen.queryByRole( 'button', {
name: 'Remove link',
} );
expect( unLinkButton ).toBeVisible();
await user.click( unLinkButton );
expect( mockOnRemove ).toHaveBeenCalled();
// Should revert back to editing mode.
expect(
screen.getByRole( 'combobox', { name: 'Link' } )
).toBeVisible();
} );
} );
} );
describe( 'Searching for a link', () => {
it( 'should display loading UI when input is valid but search results have yet to be returned', async () => {
const user = userEvent.setup();
const searchTerm = 'Hello';
let resolver;
mockFetchSearchSuggestions.mockImplementation(
() =>
new Promise( ( resolve ) => {
resolver = resolve;
} )
);
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchTerm );
expect( await screen.findByRole( 'presentation' ) ).toBeVisible();
expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
// make the search suggestions fetch return a response
resolver( fauxEntitySuggestions );
expect( await screen.findByRole( 'listbox' ) ).toBeVisible();
expect( screen.queryByRole( 'presentation' ) ).not.toBeInTheDocument();
} );
it.each( [ 'With spaces', 'Uppercase', 'lowercase' ] )(
'should display only search suggestions (and not URL result type) when current input value (e.g. %s) is not URL-like',
async ( searchTerm ) => {
const user = userEvent.setup();
const firstSuggestion = fauxEntitySuggestions[ 0 ];
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchTerm );
const searchResultElements = within(
await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} )
).getAllByRole( 'option' );
expect( searchResultElements ).toHaveLength(
fauxEntitySuggestions.length
);
expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' );
// Check that a search suggestion shows up corresponding to the data.
expect( searchResultElements[ 0 ] ).toHaveTextContent(
firstSuggestion.title
);
expect( searchResultElements[ 0 ] ).toHaveTextContent(
firstSuggestion.type
);
// The fallback URL suggestion should not be shown when input is not URL-like.
expect(
searchResultElements[ searchResultElements.length - 1 ]
).not.toHaveTextContent( 'Press ENTER to add this link' );
}
);
it.each( [
[ 'https://wordpress.org', 'link' ],
[ 'http://wordpress.org', 'link' ],
[ 'www.wordpress.org', 'link' ],
[ 'wordpress.org', 'link' ],
[ 'ftp://wordpress.org', 'link' ],
[ 'mailto:hello@wordpress.org', 'mailto' ],
[ 'tel:123456789', 'tel' ],
[ '#internal', 'internal' ],
] )(
'should display only URL result when current input value is URL-like (e.g. %s)',
async ( searchTerm, type ) => {
const user = userEvent.setup();
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchTerm );
const searchResultElement = within(
await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} )
).getByRole( 'option' );
expect( searchResultElement ).toBeInTheDocument();
// Should only be the `URL` suggestion.
expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' );
expect( searchResultElement ).toHaveTextContent( searchTerm );
expect( searchResultElement ).toHaveTextContent( type );
expect( searchResultElement ).toHaveTextContent(
'Press ENTER to add this link'
);
}
);
it( 'should trim search term', async () => {
const user = userEvent.setup();
const searchTerm = ' Hello ';
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchTerm );
const searchResults = await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} );
const searchResultTextHighlightElements = within( searchResults )
.getAllByRole( 'option' )
// TODO: Change to `getByRole( 'mark' )` when officially supported by
// WAI-ARIA 1.3 - see https://w3c.github.io/aria/#mark
// eslint-disable-next-line testing-library/no-node-access
.map( ( searchResult ) => searchResult.querySelector( 'mark' ) )
.flat()
.filter( Boolean );
expect( searchResultTextHighlightElements ).toHaveLength( 3 );
// Make sure there are no `mark` elements which contain anything other
// than the trimmed search term (ie: no whitespace).
expect(
searchResultTextHighlightElements.every(
( mark ) => mark.innerHTML === 'Hello'
)
).toBe( true );
// Implementation detail test to ensure that the fetch handler is called
// with the trimmed search value. We do this because we are mocking out
// the fetch handler in our test so we need to assert it would be called
// correctly in a real world scenario.
expect( mockFetchSearchSuggestions ).toHaveBeenCalledWith(
'Hello',
expect.anything()
);
} );
it( 'should not call search handler when showSuggestions is false', async () => {
const user = userEvent.setup();
render( <LinkControl showSuggestions={ false } /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, 'anything' );
const searchResultsField = screen.queryByRole( 'listbox' );
expect( searchResultsField ).not.toBeInTheDocument();
expect( mockFetchSearchSuggestions ).not.toHaveBeenCalled();
} );
it( 'should not display a URL suggestion when input is not likely to be a URL.', async () => {
const searchTerm = 'unlikelytobeaURL';
const user = userEvent.setup();
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchTerm );
const searchResultElements = within(
await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} )
).getAllByRole( 'option' );
const lastSearchResultItem =
searchResultElements[ searchResultElements.length - 1 ];
// We should see a search result for each of the expect search suggestions.
expect( searchResultElements ).toHaveLength(
fauxEntitySuggestions.length
);
// The URL search suggestion should not exist.
expect( lastSearchResultItem ).not.toHaveTextContent(
'Press ENTER to add this link'
);
} );
it( 'should not display a URL suggestion as a default fallback when noURLSuggestion is passed.', async () => {
const user = userEvent.setup();
render( <LinkControl noURLSuggestion /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, 'couldbeurlorentitysearchterm' );
const searchResultElements = within(
await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} )
).getAllByRole( 'option' );
// We should see a search result for each of the expect search suggestions and nothing else.
expect( searchResultElements ).toHaveLength(
fauxEntitySuggestions.length
);
} );
} );
describe( 'Manual link entry', () => {
it.each( [
[ 'https://make.wordpress.org' ], // Explicit https.
[ 'http://make.wordpress.org' ], // Explicit http.
[ 'www.wordpress.org' ], // Usage of "www".
] )(
'should display a single suggestion result when the current input value is URL-like (eg: %s)',
async ( searchTerm ) => {
const user = userEvent.setup();
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchTerm );
const searchResultElements = within(
await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} )
).getByRole( 'option' );
expect( searchResultElements ).toBeVisible();
expect( searchResultElements ).toHaveTextContent( searchTerm );
expect( searchResultElements ).toHaveTextContent(
'Press ENTER to add this link'
);
}
);
describe( 'Handling of empty values', () => {
const testTable = [
[ 'containing only spaces', ' ' ],
[ 'containing only tabs', '[Tab]' ],
[ 'from strings with no length', '' ],
];
it.each( testTable )(
'should not allow creation of links %s when using the keyboard',
async ( _desc, searchString ) => {
const user = userEvent.setup();
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
if ( searchString.length ) {
// Simulate searching for a term.
await user.type( searchInput, searchString );
} else {
// Simulate clearing the search term.
await user.clear( searchInput );
}
// Attempt to submit the empty search value in the input.
await user.keyboard( '[Enter]' );
// Verify the UI hasn't allowed submission because
// the search input is still visible.
expect( searchInput ).toBeVisible();
}
);
it.each( testTable )(
'should not allow editing of links to a new link %s via the UI "submit" button',
async ( _desc, searchString ) => {
const user = userEvent.setup();
render(
<LinkControl
value={ fauxEntitySuggestions[ 0 ] }
forceIsEditingLink
/>
);
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Remove the existing link.
await user.clear( searchInput );
if ( searchString.length ) {
await user.type( searchInput, searchString );
} else {
// Simulate clearing the search term.
await user.clear( searchInput );
}
const submitButton = screen.queryByRole( 'button', {
name: 'Save',
} );
// Verify the submission UI is disabled.
expect( submitButton ).toBeVisible();
expect( submitButton ).toHaveAttribute(
'aria-disabled',
'true'
);
// Attempt to submit the empty search value in the input.
await user.click( submitButton );
// Verify the UI hasn't allowed submission because
// the search input is still visible.
expect( searchInput ).toBeVisible();
}
);
} );
describe( 'Handling cancellation', () => {
it( 'should not show cancellation button during link creation', async () => {
const mockOnRemove = jest.fn();
render( <LinkControl onRemove={ mockOnRemove } /> );
const cancelButton = screen.queryByRole( 'button', {
name: 'Cancel',
} );
expect( cancelButton ).not.toBeInTheDocument();
} );
it( 'should allow cancellation of the link editing process and reset any entered values', async () => {
const user = userEvent.setup();
const initialLink = fauxEntitySuggestions[ 0 ];
const LinkControlConsumer = () => {
const [ link, setLink ] = useState( initialLink );
return (
<LinkControl
value={ link }
onChange={ ( suggestion ) => {
setLink( suggestion );
} }
hasTextControl
/>
);
};
render( <LinkControlConsumer /> );
let linkPreview = screen.getByRole( 'group', {
name: 'Manage link',
} );
expect( linkPreview ).toBeInTheDocument();
// Click the "Edit" button to trigger into the editing mode.
let editButton = screen.queryByRole( 'button', {
name: 'Edit link',
} );
await user.click( editButton );
await toggleSettingsDrawer( user );
let searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
let textInput = screen.getByRole( 'textbox', {
name: 'Text',
} );
// Make a change to the search input.
await user.type( searchInput, 'This URL value was changed!' );
// Make a change to the text input.
await user.type( textInput, 'This text value was changed!' );
const cancelButton = screen.queryByRole( 'button', {
name: 'Cancel',
} );
// Cancel the editing process.
await user.click( cancelButton );
linkPreview = screen.getByRole( 'group', {
name: 'Manage link',
} );
expect( linkPreview ).toBeInTheDocument();
// Re-query the edit button as it's been replaced.
editButton = screen.queryByRole( 'button', {
name: 'Edit link',
} );
await user.click( editButton );
await toggleSettingsDrawer( user );
// Re-query the inputs as they have been replaced.
searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
textInput = screen.getByRole( 'textbox', {
name: 'Text',
} );
// Expect to see the original link values and **not** the changed values.
expect( searchInput ).toHaveValue( initialLink.url );
expect( textInput ).toHaveValue( initialLink.text );
} );
it( 'should call onCancel callback when cancelling if provided', async () => {
const user = userEvent.setup();
const mockOnCancel = jest.fn();
render(
<LinkControl
value={ fauxEntitySuggestions[ 0 ] }
onCancel={ mockOnCancel }
forceIsEditingLink
/>
);
const cancelButton = screen.queryByRole( 'button', {
name: 'Cancel',
} );
await user.click( cancelButton );
// Verify the consumer can handle the cancellation.
expect( mockOnCancel ).toHaveBeenCalled();
} );
} );
describe( 'Alternative link protocols and formats', () => {
it.each( [
[ 'mailto:example123456@wordpress.org', 'mailto' ],
[ 'tel:example123456@wordpress.org', 'tel' ],
[ '#internal-anchor', 'internal' ],
] )(
'should recognise "%s" as a %s link and handle as manual entry by displaying a single suggestion',
async ( searchTerm, searchType ) => {
const user = userEvent.setup();
render( <LinkControl /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchTerm );
const searchResultElements = within(
await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} )
).getByRole( 'option' );
expect( searchResultElements ).toBeVisible();
expect( searchResultElements ).toHaveTextContent( searchTerm );
expect( searchResultElements ).toHaveTextContent( searchType );
expect( searchResultElements ).toHaveTextContent(
'Press ENTER to add this link'
);
}
);
} );
} );
describe( 'Link submission', () => {
it( 'should show a submit button when creating a link', async () => {
const user = userEvent.setup();
const LinkControlConsumer = () => {
const [ link, setLink ] = useState( {} );
return <LinkControl value={ link } onChange={ setLink } />;
};
render( <LinkControlConsumer /> );
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
const submitButton = screen.getByRole( 'button', {
name: 'Submit',
} );
expect( submitButton ).toBeVisible();
expect( submitButton ).toHaveAttribute( 'aria-disabled', 'true' );
// Click the button and check it's not possible to prematurely submit the link.
await user.click( submitButton );
expect( searchInput ).toBeVisible();
expect( submitButton ).toBeVisible();
await user.type( searchInput, 'https://wordpress.org' );
expect( submitButton ).toHaveAttribute( 'aria-disabled', 'false' );
} );
it( 'should show a submit button when editing a link', async () => {
const user = userEvent.setup();
const LinkControlConsumer = () => {
const [ link, setLink ] = useState( fauxEntitySuggestions[ 0 ] );
return (
<LinkControl
forceIsEditingLink
value={ link }
onChange={ setLink }
/>
);
};
render( <LinkControlConsumer /> );
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
const createSubmitButton = screen.queryByRole( 'button', {
name: 'Submit',
} );
// Check the submit button for "creation" of links is not displayed.
expect( createSubmitButton ).not.toBeInTheDocument();
const editSubmitButton = screen.getByRole( 'button', {
name: 'Save',
} );
expect( editSubmitButton ).toBeVisible();
expect( editSubmitButton ).toHaveAttribute( 'aria-disabled', 'true' );
// Click the button and check it's not possible to prematurely submit the link.
await user.click( editSubmitButton );
expect( searchInput ).toBeVisible();
expect( editSubmitButton ).toBeVisible();
await user.type( searchInput, '#appendtolinktext' );
// As typing triggers the search handler, we need to wait for the
// search results to be returned. We can use the presence of the
// search results listbox as a proxy for this.
expect( await screen.findByRole( 'listbox' ) ).toBeVisible();
expect( editSubmitButton ).toHaveAttribute( 'aria-disabled', 'false' );
} );
} );
describe( 'Default search suggestions', () => {
it( 'should display a list of initial search suggestions when there is no search value or suggestions', async () => {
render( <LinkControl showInitialSuggestions /> );
expect(
await screen.findByRole( 'listbox', {
name: 'Suggestions',
} )
).toBeVisible();
// Verify input has no value has default suggestions should only show
// when this does not have a value.
// Search Input UI.
expect( screen.getByRole( 'combobox', { name: 'Link' } ) ).toHaveValue(
''
);
// Ensure only called once as a guard against potential infinite
// re-render loop within `componentDidUpdate` calling `updateSuggestions`
// which has calls to `setState` within it.
expect( mockFetchSearchSuggestions ).toHaveBeenCalledTimes( 1 );
// Verify the search results already display the initial suggestions.
// `LinkControl` internally always limits the number of initial suggestions to 3.
expect( screen.queryAllByRole( 'option' ) ).toHaveLength( 3 );
} );
it( 'should not display initial suggestions when input value is present', async () => {
const user = userEvent.setup();
// Render with an initial value an ensure that no initial suggestions are shown.
const initialValue = fauxEntitySuggestions[ 0 ];
render( <LinkControl showInitialSuggestions value={ initialValue } /> );
// Click the "Edit/Change" button and check initial suggestions are not
// shown.
const currentLinkUI = screen.getByRole( 'group', {
name: 'Manage link',
} );
const currentLinkBtn = within( currentLinkUI ).getByRole( 'button', {
name: 'Edit link',
} );
await user.click( currentLinkBtn );
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Search input is set to the URL value.
expect( searchInput ).toHaveValue( initialValue.url );
// Ensure no initial suggestions are shown.
expect(
screen.queryByRole( 'listbox', {
name: /Search results for.*/,
} )
).not.toBeInTheDocument();
expect( mockFetchSearchSuggestions ).not.toHaveBeenCalled();
} );
it( 'should display initial suggestions when input value is manually deleted', async () => {
const user = userEvent.setup();
const searchTerm = 'Hello world';
render( <LinkControl showInitialSuggestions /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchTerm );
expect( searchInput ).toHaveValue( searchTerm );
const searchResultsList = await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} );
expect( searchResultsList ).toBeVisible();
expect(
within( searchResultsList ).getAllByRole( 'option' )
).toHaveLength( 4 );
// Delete the text.
await userEvent.clear( searchInput );
// Check the input is empty now.
expect( searchInput ).toHaveValue( '' );
const initialResultsList = await screen.findByRole( 'listbox', {
name: 'Suggestions',
} );
expect(
within( initialResultsList ).getAllByRole( 'option' )
).toHaveLength( 3 );
} );
it( 'should not display initial suggestions when there are no recently updated pages/posts', async () => {
// Force API returning empty results for recently updated Pages.
mockFetchSearchSuggestions.mockImplementation( async () => [] );
render( <LinkControl showInitialSuggestions /> );
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
const searchResultsField = screen.queryByRole( 'listbox', {
name: 'Suggestions',
} );
expect( searchResultsField ).not.toBeInTheDocument();
expect( searchInput ).toHaveAttribute( 'aria-expanded', 'false' );
} );
} );
describe( 'Creating Entities (eg: Posts, Pages)', () => {
const noResults = [];
beforeEach( () => {
// Force returning empty results for existing Pages. Doing this means that the only item
// shown should be "Create Page" suggestion because there will be no search suggestions
// and our input does not conform to a direct entry schema (eg: a URL).
mockFetchSearchSuggestions.mockImplementation( () =>
Promise.resolve( noResults )
);
} );
it.each( [
[ 'HelloWorld', 'without spaces' ],
[ 'Hello World', 'with spaces' ],
] )(
'should allow creating a link for a valid Entity title "%s" (%s)',
async ( entityNameText ) => {
const user = userEvent.setup();
let resolver;
const createSuggestion = ( title ) =>
new Promise( ( resolve ) => {
resolver = () =>
resolve( {
title,
id: 123,
url: '/?p=123',
type: 'page',
} );
} );
const LinkControlConsumer = () => {
const [ link, setLink ] = useState( null );
return (
<LinkControl
value={ link }
onChange={ ( suggestion ) => {
setLink( suggestion );
} }
createSuggestion={ createSuggestion }
/>
);
};
render( <LinkControlConsumer /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, entityNameText );
const searchResults = await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} );
const createButton = within( searchResults ).getByRole( 'option', {
name: /^Create:/,
} );
expect( createButton ).toBeVisible();
expect( createButton ).toHaveTextContent( entityNameText );
// No need to wait in this test because we control the Promise
// resolution manually via the `resolver` reference.
await user.click( createButton );
// Check for loading indicator.
const loadingIndicator = screen.getByText( 'Creating…' );
const currentLinkLabel = screen.queryByRole( 'group', {
name: 'Manage link',
} );
expect( currentLinkLabel ).not.toBeInTheDocument();
expect( loadingIndicator ).toBeVisible();
expect( loadingIndicator ).toHaveClass(
'block-editor-link-control__loading'
);
// Resolve the `createSuggestion` promise.
resolver();
const currentLink = await screen.findByRole( 'group', {
name: 'Manage link',
} );
expect( currentLink ).toHaveTextContent( entityNameText );
expect( currentLink ).toHaveTextContent( '/?p=123' );
}
);
it( 'should allow createSuggestion prop to return a non-Promise value', async () => {
const user = userEvent.setup();
const LinkControlConsumer = () => {
const [ link, setLink ] = useState( null );
return (
<LinkControl
value={ link }
onChange={ ( suggestion ) => {
setLink( suggestion );
} }
createSuggestion={ ( title ) => ( {
title,
id: 123,
url: '/?p=123',
type: 'page',
} ) }
/>
);
};
render( <LinkControlConsumer /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, 'Some new page to create' );
const searchResults = await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} );
const createButton = within( searchResults ).getByRole( 'option', {
name: /^Create:/,
} );
await user.click( createButton );
const currentLink = screen.getByRole( 'group', {
name: 'Manage link',
} );
expect( currentLink ).toHaveTextContent( 'Some new page to create' );
expect( currentLink ).toHaveTextContent( '/?p=123' );
} );
it( 'should allow creation of entities via the keyboard', async () => {
const user = userEvent.setup();
const entityNameText = 'A new page to be created';
const LinkControlConsumer = () => {
const [ link, setLink ] = useState( null );
return (
<LinkControl
value={ link }
onChange={ ( suggestion ) => {
setLink( suggestion );
} }
createSuggestion={ ( title ) =>
Promise.resolve( {
title,
id: 123,
url: '/?p=123',
type: 'page',
} )
}
/>
);
};
render( <LinkControlConsumer /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, entityNameText );
const searchResults = await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} );
// Step down into the search results, selecting the first result item.
triggerArrowDown( searchInput );
// Check that the create button is in the results and that it's selected
const createButton = within( searchResults ).getByRole( 'option', {
name: /^Create:/,
selected: true,
} );
expect( createButton ).toBeVisible();
expect( searchInput ).toHaveFocus();
triggerEnter( searchInput );
expect(
await screen.findByRole( 'group', {
name: 'Manage link',
} )
).toHaveTextContent( entityNameText );
} );
it( 'should allow customisation of button text', async () => {
const user = userEvent.setup();
const entityNameText = 'A new page to be created';
const LinkControlConsumer = () => {
return (
<LinkControl
createSuggestion={ () => {} }
createSuggestionButtonText="Custom suggestion text"
/>
);
};
render( <LinkControlConsumer /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, entityNameText );
const searchResults = await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} );
const createButton = within( searchResults ).getByRole( 'option', {
name: /Custom suggestion text/,
} );
expect( createButton ).toBeVisible();
} );
describe( 'Do not show create option', () => {
it.each( [ [ undefined ], [ null ], [ false ] ] )(
'should not show not show an option to create an entity when "createSuggestion" handler is %s',
async ( handler ) => {
render( <LinkControl createSuggestion={ handler } /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
const searchResultsField = screen.queryByRole( 'listbox' );
// Verify input has no value.
expect( searchInput ).toHaveValue( '' );
expect( searchResultsField ).not.toBeInTheDocument(); // Shouldn't exist!
}
);
it( 'should not show an option to create an entity when input is empty', async () => {
render(
<LinkControl
showInitialSuggestions // Should show even if we're not showing initial suggestions.
createSuggestion={ jest.fn() }
/>
);
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
const searchResultsField = screen.queryByRole( 'listbox' );
// Verify input has no value.
expect( searchInput ).toHaveValue( '' );
expect( searchResultsField ).not.toBeInTheDocument(); // Shouldn't exist!
} );
it.each( [
'https://wordpress.org',
'www.wordpress.org',
'mailto:example123456@wordpress.org',
'tel:example123456@wordpress.org',
'#internal-anchor',
] )(
'should not show option to "Create Page" when text is a form of direct entry (eg: %s)',
async ( inputText ) => {
const user = userEvent.setup();
render( <LinkControl createSuggestion={ jest.fn() } /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, inputText );
const searchResults = await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} );
const createButton = within( searchResults ).queryByRole(
'option',
{ name: /New page/ }
);
expect( createButton ).not.toBeInTheDocument(); // Shouldn't exist!
}
);
} );
describe( 'Error handling', () => {
it( 'should display human-friendly, perceivable error notice and re-show create button and search input if page creation request fails', async () => {
const user = userEvent.setup();
const searchText = 'This page to be created';
let searchInput;
const throwsError = () => {
throw new Error( 'API response returned invalid entity.' ); // This can be any error and msg.
};
const createSuggestion = () => Promise.reject( throwsError() );
render( <LinkControl createSuggestion={ createSuggestion } /> );
// Search Input UI.
searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchText );
const searchResults = await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} );
const createButton = within( searchResults ).getByRole( 'option', {
name: /^Create:/,
} );
await user.click( createButton );
searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
const errorNotice = screen.getAllByText(
'API response returned invalid entity.'
)[ 1 ];
// Catch the error in the test to avoid test failures.
expect( throwsError ).toThrow( Error );
// Check human readable error notice is perceivable.
expect( errorNotice ).toBeVisible();
// eslint-disable-next-line testing-library/no-node-access
expect( errorNotice.parentElement ).toHaveClass(
'block-editor-link-control__search-error'
);
// Verify input is repopulated with original search text.
expect( searchInput ).toBeVisible();
expect( searchInput ).toHaveValue( searchText );
} );
} );
} );
describe( 'Selecting links', () => {
it( 'should display a selected link corresponding to the provided "currentLink" prop', () => {
const selectedLink = fauxEntitySuggestions[ 0 ];
const LinkControlConsumer = () => {
const [ link ] = useState( selectedLink );
return <LinkControl value={ link } />;
};
render( <LinkControlConsumer /> );
const currentLink = screen.getByRole( 'group', {
name: 'Manage link',
} );
const currentLinkAnchor = screen.getByRole( 'link', {
name: `${ selectedLink.title } (opens in a new tab)`,
} );
expect( currentLink ).toBeVisible();
expect(
screen.queryByRole( 'button', { name: 'Edit link' } )
).toBeVisible();
expect( currentLinkAnchor ).toBeVisible();
} );
it( 'should hide "selected" link UI and display search UI prepopulated with previously selected link title when "Change" button is clicked', async () => {
const user = userEvent.setup();
const selectedLink = fauxEntitySuggestions[ 0 ];
const LinkControlConsumer = () => {
const [ link, setLink ] = useState( selectedLink );
return (
<LinkControl
value={ link }
onChange={ ( suggestion ) => setLink( suggestion ) }
/>
);
};
render( <LinkControlConsumer /> );
// Required in order to select the button below.
let currentLinkUI = screen.getByRole( 'group', {
name: 'Manage link',
} );
const currentLinkBtn = within( currentLinkUI ).getByRole( 'button', {
name: 'Edit link',
} );
// Simulate searching for a term.
await user.click( currentLinkBtn );
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
currentLinkUI = screen.queryByRole( 'group', {
name: 'Manage link',
} );
// We should be back to showing the search input.
expect( searchInput ).toBeVisible();
expect( searchInput ).toHaveValue( selectedLink.url ); // Prepopulated with previous link's URL.
expect( currentLinkUI ).not.toBeInTheDocument();
} );
describe( 'Selection using mouse click', () => {
it.each( [
[ 'entity', 'hello world', fauxEntitySuggestions[ 0 ] ], // Entity search.
[
'url',
'https://www.wordpress.org',
{
id: '1',
title: 'https://www.wordpress.org',
url: 'https://www.wordpress.org',
type: 'URL',
},
], // Url.
] )(
'should display a current selected link UI when a %s suggestion for the search "%s" is clicked',
async ( type, searchTerm, selectedLink ) => {
const user = userEvent.setup();
const LinkControlConsumer = () => {
const [ link, setLink ] = useState();
return (
<LinkControl
value={ link }
onChange={ ( suggestion ) => setLink( suggestion ) }
/>
);
};
render( <LinkControlConsumer /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchTerm );
const searchResultElements = within(
await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} )
).getAllByRole( 'option' );
const firstSearchSuggestion = searchResultElements[ 0 ];
// Simulate selecting the first of the search suggestions.
await user.click( firstSearchSuggestion );
const currentLinkAnchor = screen.getByRole( 'link', {
name: `${ selectedLink.title } (opens in a new tab)`,
} );
// Check that this suggestion is now shown as selected.
expect(
screen.getByRole( 'button', { name: 'Edit link' } )
).toBeVisible();
expect( currentLinkAnchor ).toBeVisible();
}
);
} );
describe( 'Selection using keyboard', () => {
it.each( [
[ 'entity', 'hello world', fauxEntitySuggestions[ 0 ] ], // Entity search.
[
'url',
'https://www.wordpress.org',
{
id: '1',
title: 'https://www.wordpress.org',
url: 'https://www.wordpress.org',
type: 'URL',
},
], // Url.
] )(
'should display a current selected link UI when an %s suggestion for the search "%s" is selected using the keyboard',
async ( type, searchTerm, selectedLink ) => {
const user = userEvent.setup();
const LinkControlConsumer = () => {
const [ link, setLink ] = useState();
return (
<LinkControl
value={ link }
onChange={ ( suggestion ) => setLink( suggestion ) }
/>
);
};
const { container } = render( <LinkControlConsumer /> );
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Simulate searching for a term.
await user.type( searchInput, searchTerm );
const searchResults = await screen.findByRole( 'listbox', {
name: /Search results for.*/,
} );
// Step down into the search results, highlighting the first result item.
triggerArrowDown( searchInput );
const searchResultElements =
within( searchResults ).getAllByRole( 'option' );
const firstSearchSuggestion = searchResultElements[ 0 ];
const secondSearchSuggestion = searchResultElements[ 1 ];
let selectedSearchResultElement = screen.getByRole( 'option', {
selected: true,
} );
// We should have highlighted the first item using the keyboard.
expect( selectedSearchResultElement ).toBe(
firstSearchSuggestion
);
// Only entity searches contain more than 1 suggestion.
if ( type === 'entity' ) {
// Check we can go down again using the down arrow.
triggerArrowDown( searchInput );
selectedSearchResultElement = screen.getByRole( 'option', {
selected: true,
} );
// We should have highlighted the first item using the keyboard
// eslint-disable-next-line jest/no-conditional-expect
expect( selectedSearchResultElement ).toBe(
secondSearchSuggestion
);
// Check we can go back up via up arrow.
triggerArrowUp( searchInput );
selectedSearchResultElement = screen.getByRole( 'option', {
selected: true,
} );
// We should be back to highlighting the first search result again
// eslint-disable-next-line jest/no-conditional-expect
expect( selectedSearchResultElement ).toBe(
firstSearchSuggestion
);
}
// Submit the selected item as the current link.
triggerEnter( searchInput );
// Check that the suggestion selected via is now shown as selected.
const currentLink = screen.getByRole( 'group', {
name: 'Manage link',
} );
const currentLinkAnchor = screen.getByRole( 'link', {
name: `${ selectedLink.title } (opens in a new tab)`,
} );
// Make sure focus is retained after submission.
// eslint-disable-next-line testing-library/no-node-access
expect( container.firstChild ).toHaveFocus();
expect( currentLink ).toBeVisible();
expect(
screen.getByRole( 'button', { name: 'Edit link' } )
).toBeVisible();
expect( currentLinkAnchor ).toBeVisible();
}
);
it( 'should allow selection of initial search results via the keyboard', async () => {
render( <LinkControl showInitialSuggestions /> );
expect(
await screen.findByRole( 'listbox', {
name: 'Suggestions',
} )
).toBeVisible();
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', {
name: 'Link',
} );
// Step down into the search results, highlighting the first result item.
triggerArrowDo