UNPKG

@wordpress/block-editor

Version:
1,790 lines (1,428 loc) • 71.7 kB
/** * 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