/**
* External dependencies
*/
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* WordPress dependencies
*/
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import LinkControl from '../';
import {
fauxEntitySuggestions,
fetchFauxEntitySuggestions,
uniqueId,
} from './fixtures';
// Mock debounce() so that it runs instantly.
jest.mock( '@wordpress/compose/src/utils/debounce', () => ( {
debounce: ( fn ) => {
fn.cancel = jest.fn();
return fn;
},
} ) );
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 unecessary. 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.useRealTimers();
/**
* Wait for next tick of event loop. This is required
* because the `fetchSearchSuggestions` Promise will
* resolve on the next tick of the event loop (this is
* inline with the Promise spec). As a result we need to
* wait on this loop to "tick" before we can expect the UI
* to have updated.
*/
function eventLoopTick() {
return new Promise( ( resolve ) => setImmediate( resolve ) );
}
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.
} );
function getURLInput() {
return screen.queryByRole( 'combobox', { name: 'URL' } );
}
function getSearchResults( container ) {
const input = getURLInput();
// The input has `aria-controls` to indicate that it owns (and is related to)
// the search results with `role="listbox"`.
const relatedSelector = input.getAttribute( 'aria-controls' );
// Select by relationship as well as role.
return container.querySelectorAll(
`#${ relatedSelector }[role="listbox"] [role="option"]`
);
}
function getCurrentLink() {
return screen.queryByLabelText( 'Currently selected' );
}
function getSelectedResultElement() {
return screen.queryByRole( 'option', { selected: true } );
}
/**
* 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( );
// Search Input UI.
const searchInput = getURLInput();
expect( searchInput ).toBeInTheDocument();
} );
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( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
expect( screen.queryByText( '://' ) ).not.toBeInTheDocument();
} );
describe( 'forceIsEditingLink', () => {
it( 'undefined', () => {
render( );
expect( getURLInput() ).not.toBeInTheDocument();
} );
it( 'true', () => {
render(
);
expect( getURLInput() ).toBeVisible();
} );
it( 'false', async () => {
const user = userEvent.setup();
const { rerender } = render(
);
// Click the "Edit" button to trigger into the editing mode.
const editButton = screen.queryByRole( 'button', {
name: 'Edit',
} );
await user.click( editButton );
expect( getURLInput() ).toBeVisible();
// If passed `forceIsEditingLink` of `false` while editing, should
// forcefully reset to the preview state.
rerender(
);
expect( getURLInput() ).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 explictly 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(
);
const linkPreview = getCurrentLink();
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( );
const unLinkButton = screen.queryByRole( 'button', {
name: 'Unlink',
} );
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(
);
const unLinkButton = screen.queryByRole( 'button', {
name: 'Unlink',
} );
expect( unLinkButton ).toBeVisible();
await user.click( unLinkButton );
expect( mockOnRemove ).toHaveBeenCalled();
} );
} );
} );
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;
const fauxRequest = () =>
new Promise( ( resolve ) => {
resolver = resolve;
} );
mockFetchSearchSuggestions.mockImplementation( fauxRequest );
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
const searchResultElements = getSearchResults( container );
let loadingUI = screen.queryByRole( 'presentation' );
expect( searchResultElements ).toHaveLength( 0 );
expect( loadingUI ).toBeVisible();
act( () => {
resolver( fauxEntitySuggestions );
} );
await eventLoopTick();
loadingUI = screen.queryByRole( 'presentation' );
expect( loadingUI ).not.toBeInTheDocument();
} );
it( 'should display only search suggestions when current input value is not URL-like', async () => {
const user = userEvent.setup();
const searchTerm = 'Hello world';
const firstFauxSuggestion = fauxEntitySuggestions[ 0 ];
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
const searchResultElements = getSearchResults( container );
expect( searchResultElements ).toHaveLength(
fauxEntitySuggestions.length
);
expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' );
// Sanity check that a search suggestion shows up corresponding to the data.
expect( searchResultElements[ 0 ] ).toHaveTextContent(
firstFauxSuggestion.title
);
expect( searchResultElements[ 0 ] ).toHaveTextContent(
firstFauxSuggestion.type
);
// The fallback URL suggestion should not be shown when input is not URL-like.
expect(
searchResultElements[ searchResultElements.length - 1 ]
).not.toHaveTextContent( 'URL' );
} );
it( 'should trim search term', async () => {
const user = userEvent.setup();
const searchTerm = ' Hello ';
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
const searchResultTextHighlightElements = Array.from(
container.querySelectorAll(
'[role="listbox"] button[role="option"] mark'
)
);
const invalidResults = searchResultTextHighlightElements.find(
( mark ) => mark.innerHTML !== 'Hello'
);
// Given we're mocking out the results we should always have 4 mark elements.
expect( searchResultTextHighlightElements ).toHaveLength( 4 );
// Make sure there are no `mark` elements which contain anything other
// than the trimmed search term (ie: no whitespace).
expect( invalidResults ).toBeFalsy();
// 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();
const { container } = render(
);
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( 'anything' );
const searchResultElements = getSearchResults( container );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
expect( searchResultElements ).toHaveLength( 0 );
expect( mockFetchSearchSuggestions ).not.toHaveBeenCalled();
} );
it.each( [
[ 'couldbeurlorentitysearchterm' ],
[ 'ThisCouldAlsoBeAValidURL' ],
] )(
'should display a URL suggestion as a default fallback for the search term "%s" which could potentially be a valid url.',
async ( searchTerm ) => {
const user = userEvent.setup();
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
const searchResultElements = getSearchResults( container );
const lastSearchResultItem =
searchResultElements[ searchResultElements.length - 1 ];
// We should see a search result for each of the expect search suggestions
// plus 1 additional one for the fallback URL suggestion.
expect( searchResultElements ).toHaveLength(
fauxEntitySuggestions.length + 1
);
// The last item should be a URL search suggestion.
expect( lastSearchResultItem ).toHaveTextContent( searchTerm );
expect( lastSearchResultItem ).toHaveTextContent( 'URL' );
expect( lastSearchResultItem ).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();
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( 'couldbeurlorentitysearchterm' );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
const searchResultElements = getSearchResults( container );
// 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();
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
const searchResultElements = getSearchResults( container );
expect( searchResultElements ).toHaveLength( 1 );
expect( searchResultElements[ 0 ] ).toHaveTextContent( searchTerm );
expect( searchResultElements[ 0 ] ).toHaveTextContent( 'URL' );
expect( searchResultElements[ 0 ] ).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( );
// Search Input UI.
const searchInput = getURLInput();
let submitButton = screen.queryByRole( 'button', {
name: 'Submit',
} );
expect( submitButton ).toBeDisabled();
expect( submitButton ).toBeVisible();
searchInput.focus();
if ( searchString.length ) {
// Simulate searching for a term.
await user.keyboard( searchString );
} else {
// Simulate clearing the search term.
await userEvent.clear( searchInput );
}
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
// Attempt to submit the empty search value in the input.
await user.keyboard( '[Enter]' );
submitButton = screen.queryByRole( 'button', {
name: 'Submit',
} );
// Verify the UI hasn't allowed submission.
expect( searchInput ).toBeInTheDocument();
expect( submitButton ).toBeDisabled();
expect( submitButton ).toBeVisible();
}
);
it.each( testTable )(
'should not allow creation of links %s via the UI "submit" button',
async ( _desc, searchString ) => {
const user = userEvent.setup();
render( );
// Search Input UI.
const searchInput = getURLInput();
let submitButton = screen.queryByRole( 'button', {
name: 'Submit',
} );
expect( submitButton ).toBeDisabled();
expect( submitButton ).toBeVisible();
// Simulate searching for a term.
searchInput.focus();
if ( searchString.length ) {
// Simulate searching for a term.
await user.keyboard( searchString );
} else {
// Simulate clearing the search term.
await userEvent.clear( searchInput );
}
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
// Attempt to submit the empty search value in the input.
await user.click( submitButton );
submitButton = screen.queryByRole( 'button', {
name: 'Submit',
} );
// Verify the UI hasn't allowed submission.
expect( searchInput ).toBeInTheDocument();
expect( submitButton ).toBeDisabled();
expect( submitButton ).toBeVisible();
}
);
} );
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();
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
const searchResultElements = getSearchResults( container );
expect( searchResultElements ).toHaveLength( 1 );
expect( searchResultElements[ 0 ] ).toHaveTextContent(
searchTerm
);
expect( searchResultElements[ 0 ] ).toHaveTextContent(
searchType
);
expect( searchResultElements[ 0 ] ).toHaveTextContent(
'Press ENTER to add this link'
);
}
);
} );
} );
describe( 'Default search suggestions', () => {
it( 'should display a list of initial search suggestions when there is no search value or suggestions', async () => {
const expectedResultsLength = 3; // Set within `LinkControl`.
render( );
await eventLoopTick();
expect(
screen.queryByRole( 'listbox', {
name: 'Recently updated',
} )
).toBeVisible();
// Verify input has no value has default suggestions should only show
// when this does not have a value.
// Search Input UI.
expect( getURLInput() ).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.
expect( screen.queryAllByRole( 'option' ) ).toHaveLength(
expectedResultsLength
);
} );
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 { container } = render(
);
await eventLoopTick();
expect( mockFetchSearchSuggestions ).not.toHaveBeenCalled();
// Click the "Edit/Change" button and check initial suggestions are not
// shown.
const currentLinkUI = getCurrentLink();
const currentLinkBtn = currentLinkUI.querySelector( 'button' );
await user.click( currentLinkBtn );
const searchInput = getURLInput();
searchInput.focus();
await eventLoopTick();
const searchResultElements = getSearchResults( container );
// Search input is set to the URL value.
expect( searchInput ).toHaveValue( fauxEntitySuggestions[ 0 ].url );
// It should match any url that's like ?p= and also include a URL option.
expect( searchResultElements ).toHaveLength( 5 );
expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' );
expect( mockFetchSearchSuggestions ).toHaveBeenCalledTimes( 1 );
} );
it( 'should display initial suggestions when input value is manually deleted', async () => {
const user = userEvent.setup();
const searchTerm = 'Hello world';
const { container } = render( );
let searchResultElements;
let searchInput;
// Search Input UI.
searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
expect( searchInput ).toHaveValue( searchTerm );
searchResultElements = getSearchResults( container );
// Delete the text.
await userEvent.clear( searchInput );
await eventLoopTick();
searchResultElements = getSearchResults( container );
searchInput = getURLInput();
// Check the input is empty now.
expect( searchInput ).toHaveValue( '' );
expect(
screen.queryByRole( 'listbox', {
name: 'Recently updated',
} )
).toBeVisible();
expect( searchResultElements ).toHaveLength( 3 );
} );
it( 'should not display initial suggestions when there are no recently updated pages/posts', async () => {
const noResults = [];
// Force API returning empty results for recently updated Pages.
mockFetchSearchSuggestions.mockImplementation( () =>
Promise.resolve( noResults )
);
const { container } = render( );
await eventLoopTick();
const searchInput = getURLInput();
const searchResultElements = getSearchResults( container );
const searchResultLabel = container.querySelector(
'.block-editor-link-control__search-results-label'
);
expect( searchResultLabel ).not.toBeInTheDocument();
expect( searchResultElements ).toHaveLength( 0 );
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;
let resolvedEntity;
const createSuggestion = ( title ) =>
new Promise( ( resolve ) => {
resolver = ( arg ) => {
resolve( arg );
};
resolvedEntity = {
title,
id: 123,
url: '/?p=123',
type: 'page',
};
} );
const LinkControlConsumer = () => {
const [ link, setLink ] = useState( null );
return (
{
setLink( suggestion );
} }
createSuggestion={ createSuggestion }
/>
);
};
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( entityNameText );
await eventLoopTick();
const searchResultElements = screen.queryAllByRole( 'option' );
const createButton = Array.from( searchResultElements ).filter(
( result ) => result.innerHTML.includes( 'Create:' )
)[ 0 ];
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 );
await eventLoopTick();
// Check for loading indicator.
const loadingIndicator = container.querySelector(
'.block-editor-link-control__loading'
);
const currentLinkLabel = getCurrentLink();
expect( currentLinkLabel ).not.toBeInTheDocument();
expect( loadingIndicator ).toHaveTextContent( 'Creating' );
// Resolve the `createSuggestion` promise.
await act( async () => {
resolver( resolvedEntity );
} );
await eventLoopTick();
const currentLink = getCurrentLink();
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 (
{
setLink( suggestion );
} }
createSuggestion={ ( title ) => ( {
title,
id: 123,
url: '/?p=123',
type: 'page',
} ) }
/>
);
};
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( 'Some new page to create' );
await eventLoopTick();
// TODO: select these by aria relationship to autocomplete rather than arbitrary selector.
const searchResultElements = container.querySelectorAll(
'[role="listbox"] [role="option"]'
);
const createButton = Array.from( searchResultElements ).filter(
( result ) => result.innerHTML.includes( 'Create:' )
)[ 0 ];
await user.click( createButton );
await eventLoopTick();
const currentLink = getCurrentLink();
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 (
{
setLink( suggestion );
} }
createSuggestion={ ( title ) =>
Promise.resolve( {
title,
id: 123,
url: '/?p=123',
type: 'page',
} )
}
/>
);
};
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( entityNameText );
await eventLoopTick();
// TODO: select these by aria relationship to autocomplete rather than arbitrary selector.
const searchResultElements = container.querySelectorAll(
'[role="listbox"] [role="option"]'
);
const createButton = Array.from( searchResultElements ).filter(
( result ) => result.innerHTML.includes( 'Create:' )
)[ 0 ];
// Step down into the search results, highlighting the first result item.
triggerArrowDown( searchInput );
createButton.focus();
await user.keyboard( '[Enter]' );
searchInput.focus();
await user.keyboard( '[Enter]' );
await eventLoopTick();
expect( getCurrentLink() ).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 (
{} }
createSuggestionButtonText="Custom suggestion text"
/>
);
};
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( entityNameText );
await eventLoopTick();
// TODO: select these by aria relationship to autocomplete rather than arbitrary selector.
const searchResultElements = container.querySelectorAll(
'[role="listbox"] [role="option"]'
);
const createButton = Array.from( searchResultElements ).filter(
( result ) => result.innerHTML.includes( 'Custom suggestion text' )
)[ 0 ];
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 ) => {
const { container } = render(
);
// Await the initial suggestions to be fetched.
await eventLoopTick();
// Search Input UI.
const searchInput = getURLInput();
// TODO: select these by aria relationship to autocomplete rather than arbitrary selector.
const searchResultElements = container.querySelectorAll(
'[role="listbox"] [role="option"]'
);
const createButton = Array.from( searchResultElements ).filter(
( result ) => result.innerHTML.includes( 'Create:' )
)[ 0 ];
// Verify input has no value.
expect( searchInput ).toHaveValue( '' );
expect( createButton ).toBeFalsy(); // Shouldn't exist!
}
);
it( 'should not show not show an option to create an entity when input is empty', async () => {
const { container } = render(
);
// Await the initial suggestions to be fetched.
await eventLoopTick();
// Search Input UI.
const searchInput = getURLInput();
// TODO: select these by aria relationship to autocomplete rather than arbitrary selector.
const searchResultElements = container.querySelectorAll(
'[role="listbox"] [role="option"]'
);
const createButton = Array.from( searchResultElements ).filter(
( result ) => result.innerHTML.includes( 'New page' )
)[ 0 ];
// Verify input has no value.
expect( searchInput ).toHaveValue( '' );
expect( createButton ).toBeFalsy(); // 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();
const { container } = render(
);
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( inputText );
await eventLoopTick();
// TODO: select these by aria relationship to autocomplete rather than arbitrary selector.
const searchResultElements = container.querySelectorAll(
'[role="listbox"] [role="option"]'
);
const createButton = Array.from( searchResultElements ).filter(
( result ) => result.innerHTML.includes( 'New page' )
)[ 0 ];
expect( createButton ).toBeFalsy(); // 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() );
const { container } = render(
);
// Search Input UI.
searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchText );
await eventLoopTick();
// TODO: select these by aria relationship to autocomplete rather than arbitrary selector.
let searchResultElements = container.querySelectorAll(
'[role="listbox"] [role="option"]'
);
let createButton = Array.from( searchResultElements ).filter(
( result ) => result.innerHTML.includes( 'Create:' )
)[ 0 ];
await user.click( createButton );
await eventLoopTick();
searchInput = getURLInput();
// This is a Notice component
// we allow selecting by className here as an edge case because the
// a11y is handled via `speak`.
// See: https://github.com/WordPress/gutenberg/tree/HEAD/packages/a11y#speak.
const errorNotice = container.querySelector(
'.block-editor-link-control__search-error'
);
// Catch the error in the test to avoid test failures.
expect( throwsError ).toThrow( Error );
// Check human readable error notice is perceivable.
expect( errorNotice ).toBeVisible();
expect( errorNotice ).toHaveTextContent(
'API response returned invalid entity'
);
// Verify input is repopulated with original search text.
expect( searchInput ).toBeVisible();
expect( searchInput ).toHaveValue( searchText );
// Verify search results are re-shown and create button is available.
searchResultElements = container.querySelectorAll(
'[role="listbox"] [role="option"]'
);
createButton = Array.from( searchResultElements ).filter(
( result ) => result.innerHTML.includes( 'New page' )
)[ 0 ];
} );
} );
} );
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 ;
};
render( );
const currentLink = getCurrentLink();
const currentLinkAnchor = currentLink.querySelector(
`[href="${ selectedLink.url }"]`
);
expect( currentLink ).toHaveTextContent( selectedLink.title );
expect(
screen.queryByRole( 'button', { name: 'Edit' } )
).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 (
setLink( suggestion ) }
/>
);
};
render( );
// Required in order to select the button below.
let currentLinkUI = getCurrentLink();
const currentLinkBtn = currentLinkUI.querySelector( 'button' );
// Simulate searching for a term.
await user.click( currentLinkBtn );
const searchInput = getURLInput();
currentLinkUI = getCurrentLink();
// 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 (
setLink( suggestion ) }
/>
);
};
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
const searchResultElements = getSearchResults( container );
const firstSearchSuggestion = searchResultElements[ 0 ];
// Simulate selecting the first of the search suggestions.
await user.click( firstSearchSuggestion );
const currentLink = getCurrentLink();
const currentLinkAnchor = currentLink.querySelector(
`[href="${ selectedLink.url }"]`
);
// Check that this suggestion is now shown as selected.
expect( currentLink ).toHaveTextContent( selectedLink.title );
expect(
screen.getByRole( 'button', { name: 'Edit' } )
).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 (
setLink( suggestion ) }
/>
);
};
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
// Step down into the search results, highlighting the first result item.
triggerArrowDown( searchInput );
const searchResultElements = getSearchResults( container );
const firstSearchSuggestion = searchResultElements[ 0 ];
const secondSearchSuggestion = searchResultElements[ 1 ];
let selectedSearchResultElement = getSelectedResultElement();
// We should have highlighted the first item using the keyboard.
expect( selectedSearchResultElement ).toEqual(
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 = getSelectedResultElement();
// We should have highlighted the first item using the keyboard
// eslint-disable-next-line jest/no-conditional-expect
expect( selectedSearchResultElement ).toEqual(
secondSearchSuggestion
);
// Check we can go back up via up arrow.
triggerArrowUp( searchInput );
selectedSearchResultElement = getSelectedResultElement();
// We should be back to highlighting the first search result again
// eslint-disable-next-line jest/no-conditional-expect
expect( selectedSearchResultElement ).toEqual(
firstSearchSuggestion
);
}
// Submit the selected item as the current link.
triggerEnter( searchInput );
// Check that the suggestion selected via is now shown as selected.
const currentLink = getCurrentLink();
const currentLinkAnchor = currentLink.querySelector(
`[href="${ selectedLink.url }"]`
);
// Make sure focus is retained after submission.
expect( container ).toContainElement( document.activeElement );
expect( currentLink ).toHaveTextContent( selectedLink.title );
expect(
screen.getByRole( 'button', { name: 'Edit' } )
).toBeVisible();
expect( currentLinkAnchor ).toBeVisible();
}
);
it( 'should allow selection of initial search results via the keyboard', async () => {
const { container } = render(
);
await eventLoopTick();
expect(
screen.queryByRole( 'listbox', {
name: 'Recently updated',
} )
).toBeVisible();
// Search Input UI.
const searchInput = getURLInput();
// Step down into the search results, highlighting the first result item.
triggerArrowDown( searchInput );
await eventLoopTick();
const searchResultElements = getSearchResults( container );
const firstSearchSuggestion = searchResultElements[ 0 ];
const secondSearchSuggestion = searchResultElements[ 1 ];
let selectedSearchResultElement = getSelectedResultElement();
// We should have highlighted the first item using the keyboard.
expect( selectedSearchResultElement ).toEqual(
firstSearchSuggestion
);
// Check we can go down again using the down arrow.
triggerArrowDown( searchInput );
selectedSearchResultElement = getSelectedResultElement();
// We should have highlighted the first item using the keyboard.
expect( selectedSearchResultElement ).toEqual(
secondSearchSuggestion
);
// Check we can go back up via up arrow.
triggerArrowUp( searchInput );
selectedSearchResultElement = getSelectedResultElement();
// We should be back to highlighting the first search result again.
expect( selectedSearchResultElement ).toEqual(
firstSearchSuggestion
);
expect( mockFetchSearchSuggestions ).toHaveBeenCalledTimes( 1 );
} );
} );
} );
describe( 'Addition Settings UI', () => {
it( 'should display "New Tab" setting (in "off" mode) by default when a link is selected', async () => {
const selectedLink = fauxEntitySuggestions[ 0 ];
const expectedSettingText = 'Open in new tab';
const LinkControlConsumer = () => {
const [ link ] = useState( selectedLink );
return ;
};
const { container } = render( );
const newTabSettingLabel = screen.getByText( expectedSettingText );
expect( newTabSettingLabel ).toBeVisible();
const newTabSettingLabelForAttr =
newTabSettingLabel.getAttribute( 'for' );
const newTabSettingInput = container.querySelector(
`#${ newTabSettingLabelForAttr }`
);
expect( newTabSettingInput ).toBeVisible();
expect( newTabSettingInput ).not.toBeChecked();
} );
it( 'should display a setting control with correct default state for each of the custom settings provided', async () => {
const selectedLink = fauxEntitySuggestions[ 0 ];
const customSettings = [
{
id: 'newTab',
title: 'Open in new tab',
},
{
id: 'noFollow',
title: 'No follow',
},
];
const LinkControlConsumer = () => {
const [ link ] = useState( selectedLink );
return (
);
};
render( );
expect( screen.queryAllByRole( 'checkbox' ) ).toHaveLength( 2 );
expect(
screen.getByRole( 'checkbox', {
name: customSettings[ 0 ].title,
} )
).not.toBeChecked();
expect(
screen.getByRole( 'checkbox', {
name: customSettings[ 1 ].title,
} )
).toBeChecked();
} );
} );
describe( 'Post types', () => {
it( 'should display post type in search results of link', async () => {
const user = userEvent.setup();
const searchTerm = 'Hello world';
const { container } = render( );
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
const searchResultElements = getSearchResults( container );
searchResultElements.forEach( ( resultItem, index ) => {
expect( resultItem ).toHaveTextContent(
fauxEntitySuggestions[ index ].type
);
} );
} );
it.each( [ 'page', 'post', 'tag', 'post_tag', 'category' ] )(
'should NOT display post type in search results of %s',
async ( postType ) => {
const user = userEvent.setup();
const searchTerm = 'Hello world';
const { container } = render(
);
// Search Input UI.
const searchInput = getURLInput();
// Simulate searching for a term.
searchInput.focus();
await user.keyboard( searchTerm );
// fetchFauxEntitySuggestions resolves on next "tick" of event loop.
await eventLoopTick();
const searchResultElements = getSearchResults( container );
searchResultElements.forEach( ( resultItem, index ) => {
expect(
screen.queryByText(
resultItem,
fauxEntitySuggestions[ index ].type
)
).not.toBeInTheDocument();
} );
}
);
} );
describe( 'Rich link previews', () => {
const selectedLink = {
id: '1',
title: 'Wordpress.org', // Customize this for differentiation in assertions.
url: 'https://www.wordpress.org',
type: 'URL',
};
beforeAll( () => {
/**
* These tests require that we exercise the `fetchRichUrlData` function.
* We are therefore overwriting the mock "placeholder" with a true jest mock
* which will cause the code under test to execute the code which fetches
* rich previews.
*/
mockFetchRichUrlData = jest.fn();
} );
it( 'should not fetch or display rich previews by default', async () => {
mockFetchRichUrlData.mockImplementation( () =>
Promise.resolve( {
title: 'Blog Tool, Publishing Platform, and CMS \u2014 WordPress.org',
icon: 'https://s.w.org/favicon.ico?2',
description:
'Open source software which you can use to easily create a beautiful website, blog, or app.',
image: 'https://s.w.org/images/home/screen-themes.png?3',
} )
);
render( );
// mockFetchRichUrlData resolves on next "tick" of event loop.
await act( async () => {
await eventLoopTick();
} );
const linkPreview = getCurrentLink();
const isRichLinkPreview = linkPreview.classList.contains( 'is-rich' );
expect( mockFetchRichUrlData ).not.toHaveBeenCalled();
expect( isRichLinkPreview ).toBe( false );
} );
it( 'should display a rich preview when data is available', async () => {
mockFetchRichUrlData.mockImplementation( () =>
Promise.resolve( {
title: 'Blog Tool, Publishing Platform, and CMS \u2014 WordPress.org',
icon: 'https://s.w.org/favicon.ico?2',
description:
'Open source software which you can use to easily create a beautiful website, blog, or app.',
image: 'https://s.w.org/images/home/screen-themes.png?3',
} )
);
render( );
// mockFetchRichUrlData resolves on next "tick" of event loop.
await act( async () => {
await eventLoopTick();
} );
const linkPreview = getCurrentLink();
const isRichLinkPreview = linkPreview.classList.contains( 'is-rich' );
expect( isRichLinkPreview ).toBe( true );
} );
it( 'should not display placeholders for the image and description if neither is available in the data', async () => {
mockFetchRichUrlData.mockImplementation( () =>
Promise.resolve( {
title: '',
icon: 'https://s.w.org/favicon.ico?2',
description: '',
image: '',
} )
);
render( );
// mockFetchRichUrlData resolves on next "tick" of event loop.
await act( async () => {
await eventLoopTick();
} );
const linkPreview = getCurrentLink();
// Todo: refactor to use user-facing queries.
const hasRichImagePreview = linkPreview.querySelector(
'.block-editor-link-control__search-item-image'
);
// Todo: refactor to use user-facing queries.
const hasRichDescriptionPreview = linkPreview.querySelector(
'.block-editor-link-control__search-item-description'
);
expect( hasRichImagePreview ).not.toBeInTheDocument();
expect( hasRichDescriptionPreview ).not.toBeInTheDocument();
} );
it( 'should display a fallback when title is missing from rich data', async () => {
mockFetchRichUrlData.mockImplementation( () =>
Promise.resolve( {
icon: 'https://s.w.org/favicon.ico?2',
description:
'Open source software which you can use to easily create a beautiful website, blog, or app.',
image: 'https://s.w.org/images/home/screen-themes.png?3',
} )
);
render( );
// mockFetchRichUrlData resolves on next "tick" of event loop.
await act( async () => {
await eventLoopTick();
} );
const linkPreview = getCurrentLink();
const isRichLinkPreview = linkPreview.classList.contains( 'is-rich' );
expect( isRichLinkPreview ).toBe( true );
const titlePreview = linkPreview.querySelector(
'.block-editor-link-control__search-item-title'
);
expect( titlePreview ).toHaveTextContent( selectedLink.title );
} );
it( 'should display a fallback when icon is missing from rich data', async () => {
mockFetchRichUrlData.mockImplementation( () =>
Promise.resolve( {
title: 'Blog Tool, Publishing Platform, and CMS \u2014 WordPress.org',
description:
'Open source software which you can use to easily create a beautiful website, blog, or app.',
image: 'https://s.w.org/images/home/screen-themes.png?3',
} )
);
render( );
// mockFetchRichUrlData resolves on next "tick" of event loop.
await act( async () => {
await eventLoopTick();
} );
const linkPreview = getCurrentLink();
const isRichLinkPreview = linkPreview.classList.contains( 'is-rich' );
expect( isRichLinkPreview ).toBe( true );
const iconPreview = linkPreview.querySelector(
`.block-editor-link-control__search-item-icon`
);
const fallBackIcon = iconPreview.querySelector( 'svg' );
const richIcon = iconPreview.querySelector( 'img' );
expect( fallBackIcon ).toBeVisible();
expect( richIcon ).not.toBeInTheDocument();
} );
it.each( [ 'image', 'description' ] )(
'should not display the rich %s when it is missing from the data',
async ( dataItem ) => {
mockFetchRichUrlData.mockImplementation( () => {
const data = {
title: 'Blog Tool, Publishing Platform, and CMS \u2014 WordPress.org',
icon: 'https://s.w.org/favicon.ico?2',
description:
'Open source software which you can use to easily create a beautiful website, blog, or app.',
image: 'https://s.w.org/images/home/screen-themes.png?3',
};
delete data[ dataItem ];
return Promise.resolve( data );
} );
render( );
// mockFetchRichUrlData resolves on next "tick" of event loop.
await act( async () => {
await eventLoopTick();
} );
const linkPreview = getCurrentLink();
const isRichLinkPreview =
linkPreview.classList.contains( 'is-rich' );
expect( isRichLinkPreview ).toBe( true );
const missingDataItem = linkPreview.querySelector(
`.block-editor-link-control__search-item-${ dataItem }`
);
expect( missingDataItem ).not.toBeInTheDocument();
}
);
it.each( [
[ 'empty', {} ],
[ 'null', null ],
] )(
'should not display a rich preview when data is %s',
async ( _descriptor, data ) => {
mockFetchRichUrlData.mockImplementation( () =>
Promise.resolve( data )
);
render( );
// mockFetchRichUrlData resolves on next "tick" of event loop.
await act( async () => {
await eventLoopTick();
} );
const linkPreview = getCurrentLink();
const isRichLinkPreview =
linkPreview.classList.contains( 'is-rich' );
expect( isRichLinkPreview ).toBe( false );
}
);
it( 'should display in loading state when rich data is being fetched', async () => {
const nonResolvingPromise = () => new Promise( () => {} );
mockFetchRichUrlData.mockImplementation( nonResolvingPromise );
render( );
// mockFetchRichUrlData resolves on next "tick" of event loop.
await act( async () => {
await eventLoopTick();
} );
const linkPreview = getCurrentLink();
const isFetchingRichPreview =
linkPreview.classList.contains( 'is-fetching' );
const isRichLinkPreview = linkPreview.classList.contains( 'is-rich' );
expect( isFetchingRichPreview ).toBe( true );
expect( isRichLinkPreview ).toBe( false );
} );
it( 'should remove fetching UI indicators and fallback to standard preview if request for rich preview results in an error', async () => {
const simulateFailedFetch = () => Promise.reject();
mockFetchRichUrlData.mockImplementation( simulateFailedFetch );
render( );
// mockFetchRichUrlData resolves on next "tick" of event loop.
await act( async () => {
await eventLoopTick();
} );
const linkPreview = getCurrentLink();
const isFetchingRichPreview =
linkPreview.classList.contains( 'is-fetching' );
const isRichLinkPreview = linkPreview.classList.contains( 'is-rich' );
expect( isFetchingRichPreview ).toBe( false );
expect( isRichLinkPreview ).toBe( false );
} );
afterAll( () => {
// Remove the mock to avoid edge cases in other tests.
mockFetchRichUrlData = undefined;
} );
} );
describe( 'Controlling link title text', () => {
const selectedLink = fauxEntitySuggestions[ 0 ];
it( 'should not show a means to alter the link title text by default', async () => {
render( );
expect(
screen.queryByRole( 'textbox', { name: 'Text' } )
).not.toBeInTheDocument();
} );
it.each( [ null, undefined, ' ' ] )(
'should not show the link title text input when the URL is `%s`',
async ( urlValue ) => {
const selectedLinkWithoutURL = {
...fauxEntitySuggestions[ 0 ],
url: urlValue,
};
render(
);
expect(
screen.queryByRole( 'textbox', { name: 'Text' } )
).not.toBeInTheDocument();
}
);
it( 'should show a text input to alter the link title text when hasTextControl prop is truthy', async () => {
render(
);
expect(
screen.queryByRole( 'textbox', { name: 'Text' } )
).toBeVisible();
} );
it.each( [
[ '', 'Testing' ],
[ '(with leading and traling whitespace)', ' Testing ' ],
[
// Note: link control should always preserve the original value.
// The consumer is responsible for filtering or otherwise handling the value.
'(when containing HTML)',
'Yes this is expected behaviour',
],
] )(
"should ensure text input reflects the current link value's `title` property %s",
async ( _unused, titleValue ) => {
const linkWithTitle = { ...selectedLink, title: titleValue };
render(
);
const textInput = screen.queryByRole( 'textbox', {
name: 'Text',
} );
expect( textInput ).toHaveValue( titleValue );
}
);
it( "should ensure title value matching the text input's current value is included in onChange handler value on submit", async () => {
const user = userEvent.setup();
const mockOnChange = jest.fn();
const textValue = 'My new text value';
render(
);
const textInput = screen.queryByRole( 'textbox', { name: 'Text' } );
textInput.focus();
await userEvent.clear( textInput );
await user.keyboard( textValue );
expect( textInput ).toHaveValue( textValue );
const submitButton = screen.queryByRole( 'button', {
name: 'Submit',
} );
await user.click( submitButton );
expect( mockOnChange ).toHaveBeenCalledWith(
expect.objectContaining( {
title: textValue,
} )
);
} );
it( 'should allow `ENTER` keypress within the text field to trigger submission of value', async () => {
const user = userEvent.setup();
const textValue = 'My new text value';
const mockOnChange = jest.fn();
render(
);
const textInput = screen.queryByRole( 'textbox', { name: 'Text' } );
expect( textInput ).toBeVisible();
textInput.focus();
await userEvent.clear( textInput );
await user.keyboard( textValue );
// Attempt to submit the empty search value in the input.
triggerEnter( textInput );
expect( mockOnChange ).toHaveBeenCalledWith(
expect.objectContaining( {
title: textValue,
url: selectedLink.url,
} )
);
// The text input should not be showing as the form is submitted.
expect(
screen.queryByRole( 'textbox', { name: 'Text' } )
).not.toBeInTheDocument();
} );
} );