UNPKG

@gechiui/block-editor

Version:
1,840 lines (1,488 loc) 65.8 kB
/** * External dependencies */ import { render, unmountComponentAtNode } from 'react-dom'; import { act, Simulate } from 'react-dom/test-utils'; import { queryByText, queryByRole } from '@testing-library/react'; import { default as lodash, first, last, nth, uniqueId } from 'lodash'; /** * GeChiUI dependencies */ import { useState } from '@gechiui/element'; import { UP, DOWN, ENTER } from '@gechiui/keycodes'; /** * GeChiUI dependencies */ import { useSelect } from '@gechiui/data'; /** * Internal dependencies */ import LinkControl from '../'; import { fauxEntitySuggestions, fetchFauxEntitySuggestions } from './fixtures'; // Mock debounce() so that it runs instantly. lodash.debounce = jest.fn( ( callback ) => { callback.cancel = jest.fn(); return callback; } ); 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( '@gechiui/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( '@gechiui/data/src/components/use-dispatch', () => ( { useDispatch: () => ( { saveEntityRecords: jest.fn() } ), } ) ); /** * 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 ) ); } let container = null; beforeEach( () => { // setup a DOM element as a render target container = document.createElement( 'div' ); document.body.appendChild( container ); mockFetchSearchSuggestions.mockImplementation( fetchFauxEntitySuggestions ); } ); afterEach( () => { // cleanup on exiting unmountComponentAtNode( container ); container.remove(); container = null; mockFetchSearchSuggestions.mockReset(); mockFetchRichUrlData?.mockReset(); // conditionally reset as it may NOT be a mock } ); function getURLInput() { return container.querySelector( 'input[aria-label="URL"]' ); } function getSearchResults() { const input = getURLInput(); // The input has `aria-owns` to indicate that it owns (and is related to) // the search results with `role="listbox"`. const relatedSelector = input.getAttribute( 'aria-owns' ); // Select by relationship as well as role return container.querySelectorAll( `#${ relatedSelector }[role="listbox"] [role="option"]` ); } function getCurrentLink() { return container.querySelector( '.block-editor-link-control__search-item.is-current' ); } describe( 'Basic rendering', () => { it( 'should render', () => { act( () => { render( <LinkControl />, container ); } ); // Search Input UI const searchInput = getURLInput(); expect( searchInput ).not.toBeNull(); } ); it( 'should not render protocol in links', async () => { 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'; act( () => { render( <LinkControl />, container ); } ); // Search Input UI const searchInput = getURLInput(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: searchTerm } } ); } ); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); // Find all elements with link // Filter out the element with the text 'ENTER' because it doesn't contain link const linkElements = Array.from( container.querySelectorAll( '.block-editor-link-control__search-item-info' ) ).filter( ( elem ) => ! elem.innerHTML.includes( 'ENTER' ) ); linkElements.forEach( ( elem ) => { expect( elem.innerHTML ).not.toContain( '://' ); } ); } ); describe( 'forceIsEditingLink', () => { const isEditing = () => !! getURLInput(); it( 'undefined', () => { act( () => { render( <LinkControl value={ { url: 'https://example.com' } } />, container ); } ); expect( isEditing() ).toBe( false ); } ); it( 'true', () => { act( () => { render( <LinkControl value={ { url: 'https://example.com' } } forceIsEditingLink />, container ); } ); expect( isEditing() ).toBe( true ); } ); it( 'false', () => { act( () => { render( <LinkControl value={ { url: 'https://example.com' } } />, container ); } ); // Click the "Edit" button to trigger into the editing mode. const editButton = queryByRole( container, 'button', { name: 'Edit', } ); act( () => { Simulate.click( editButton ); } ); expect( isEditing() ).toBe( true ); // If passed `forceIsEditingLink` of `false` while editing, should // forcefully reset to the preview state. act( () => { render( <LinkControl value={ { url: 'https://example.com' } } forceIsEditingLink={ false } />, container ); } ); expect( isEditing() ).toBe( false ); } ); 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/GeChiUI/gutenberg/blob/fa5728771df7cdc86369f7157d6aa763649937a7/packages/format-library/src/link/inline.js#L151. // see also: https://github.com/GeChiUI/gutenberg/issues/17972. const valueWithEmptyURL = { url: '', id: 123, type: 'post', }; act( () => { render( <LinkControl value={ valueWithEmptyURL } forceIsEditingLink={ false } />, container ); } ); const linkPreview = queryByRole( container, 'generic', { name: 'Currently selected', } ); const isPreviewError = linkPreview.classList.contains( 'is-error' ); expect( isPreviewError ).toBe( true ); expect( queryByText( linkPreview, 'Link is empty' ) ).toBeTruthy(); } ); } ); describe( 'Unlinking', () => { it( 'should not show "Unlink" button if no onRemove handler is provided', () => { act( () => { render( <LinkControl value={ { url: 'https://example.com' } } />, container ); } ); const unLinkButton = queryByRole( container, 'button', { name: 'Unlink', } ); expect( unLinkButton ).toBeNull(); expect( unLinkButton ).not.toBeInTheDocument(); } ); it( 'should show "Unlink" button if a onRemove handler is provided', () => { const mockOnRemove = jest.fn(); act( () => { render( <LinkControl value={ { url: 'https://example.com' } } onRemove={ mockOnRemove } />, container ); } ); const unLinkButton = queryByRole( container, 'button', { name: 'Unlink', } ); expect( unLinkButton ).toBeTruthy(); expect( unLinkButton ).toBeInTheDocument(); act( () => { Simulate.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 searchTerm = 'Hello'; let resolver; const fauxRequest = () => new Promise( ( resolve ) => { resolver = resolve; } ); mockFetchSearchSuggestions.mockImplementation( fauxRequest ); act( () => { render( <LinkControl />, container ); } ); // Search Input UI const searchInput = getURLInput(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: searchTerm } } ); } ); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); const searchResultElements = getSearchResults(); let loadingUI = container.querySelector( '.components-spinner' ); expect( searchResultElements ).toHaveLength( 0 ); expect( loadingUI ).not.toBeNull(); act( () => { resolver( fauxEntitySuggestions ); } ); await eventLoopTick(); loadingUI = container.querySelector( '.components-spinner' ); expect( loadingUI ).toBeNull(); } ); it( 'should display only search suggestions when current input value is not URL-like', async () => { const searchTerm = 'Hello world'; const firstFauxSuggestion = first( fauxEntitySuggestions ); act( () => { render( <LinkControl />, container ); } ); // Search Input UI const searchInput = getURLInput(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: searchTerm } } ); } ); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. const searchResultElements = getSearchResults(); const firstSearchResultItemHTML = first( searchResultElements ) .innerHTML; const lastSearchResultItemHTML = last( searchResultElements ).innerHTML; expect( searchResultElements ).toHaveLength( fauxEntitySuggestions.length ); expect( searchInput.getAttribute( 'aria-expanded' ) ).toBe( 'true' ); // Sanity check that a search suggestion shows up corresponding to the data expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( firstFauxSuggestion.title ) ); expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( firstFauxSuggestion.type ) ); // The fallback URL suggestion should not be shown when input is not URL-like expect( lastSearchResultItemHTML ).not.toEqual( expect.stringContaining( 'URL' ) ); } ); it( 'should trim search term', async () => { const searchTerm = ' Hello '; act( () => { render( <LinkControl />, container ); } ); // Search Input UI const searchInput = container.querySelector( 'input[aria-label="URL"]' ); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: 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' ); // Grab the first argument that was passed to the fetchSuggestions // handler (which is mocked out). const mockFetchSuggestionsFirstArg = mockFetchSearchSuggestions.mock.calls[ 0 ][ 0 ]; // 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( mockFetchSuggestionsFirstArg ).toEqual( 'Hello' ); } ); it( 'should not call search handler when showSuggestions is false', async () => { act( () => { render( <LinkControl showSuggestions={ false } />, container ); } ); // Search Input UI const searchInput = getURLInput(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: 'anything' }, } ); } ); const searchResultElements = getSearchResults(); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. 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 ) => { act( () => { render( <LinkControl />, container ); } ); // Search Input UI const searchInput = getURLInput(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: searchTerm }, } ); } ); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. const searchResultElements = getSearchResults(); const lastSearchResultItemHTML = last( searchResultElements ) .innerHTML; const additionalDefaultFallbackURLSuggestionLength = 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 + additionalDefaultFallbackURLSuggestionLength ); // The last item should be a URL search suggestion expect( lastSearchResultItemHTML ).toEqual( expect.stringContaining( searchTerm ) ); expect( lastSearchResultItemHTML ).toEqual( expect.stringContaining( 'URL' ) ); expect( lastSearchResultItemHTML ).toEqual( expect.stringContaining( '按回车件以添加此链接' ) ); } ); it( 'should not display a URL suggestion as a default fallback when noURLSuggestion is passed.', async () => { act( () => { render( <LinkControl noURLSuggestion />, container ); } ); // Search Input UI const searchInput = getURLInput(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: 'couldbeurlorentitysearchterm' }, } ); } ); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. const searchResultElements = getSearchResults(); // 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.gechiui.com' ], // explicit https [ 'http://make.gechiui.com' ], // explicit http [ 'www.gechiui.com' ], // usage of "www" ] )( 'should display a single suggestion result when the current input value is URL-like (eg: %s)', async ( searchTerm ) => { act( () => { render( <LinkControl />, container ); } ); // Search Input UI const searchInput = getURLInput(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: searchTerm }, } ); } ); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); const searchResultElements = getSearchResults(); const firstSearchResultItemHTML = searchResultElements[ 0 ].innerHTML; const expectedResultsLength = 1; expect( searchResultElements ).toHaveLength( expectedResultsLength ); expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( searchTerm ) ); expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( 'URL' ) ); expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( '按回车件以添加此链接' ) ); } ); describe( 'Handling of empty values', () => { const testTable = [ [ 'containing only spaces', ' ' ], [ 'containing only tabs', ' ' ], [ 'from strings with no length', '' ], ]; it.each( testTable )( 'should not allow creation of links %s when using the keyboard', async ( _desc, searchString ) => { act( () => { render( <LinkControl />, container ); } ); // Search Input UI const searchInput = getURLInput(); let submitButton = queryByRole( container, 'button', { name: 'Submit', } ); expect( submitButton.disabled ).toBeTruthy(); expect( submitButton ).not.toBeNull(); expect( submitButton ).toBeInTheDocument(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: searchString }, } ); } ); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); // Attempt to submit the empty search value in the input. act( () => { Simulate.keyDown( searchInput, { keyCode: ENTER } ); } ); submitButton = queryByRole( container, 'button', { name: 'Submit', } ); // Verify the UI hasn't allowed submission. expect( searchInput ).toBeInTheDocument(); expect( submitButton.disabled ).toBeTruthy(); expect( submitButton ).not.toBeNull(); expect( submitButton ).toBeInTheDocument(); } ); it.each( testTable )( 'should not allow creation of links %s via the UI "submit" button', async ( _desc, searchString ) => { act( () => { render( <LinkControl />, container ); } ); // Search Input UI const searchInput = getURLInput(); let submitButton = queryByRole( container, 'button', { name: 'Submit', } ); expect( submitButton.disabled ).toBeTruthy(); expect( submitButton ).not.toBeNull(); expect( submitButton ).toBeInTheDocument(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: searchString }, } ); } ); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); // Attempt to submit the empty search value in the input. act( () => { Simulate.click( submitButton ); } ); submitButton = queryByRole( container, 'button', { name: 'Submit', } ); // Verify the UI hasn't allowed submission. expect( searchInput ).toBeInTheDocument(); expect( submitButton.disabled ).toBeTruthy(); expect( submitButton ).not.toBeNull(); expect( submitButton ).toBeInTheDocument(); } ); } ); describe( 'Alternative link protocols and formats', () => { it.each( [ [ 'mailto:example123456@www.gechiui.com', 'mailto' ], [ 'tel:example123456@www.gechiui.com', 'tel' ], [ '#internal-anchor', 'internal' ], ] )( 'should recognise "%s" as a %s link and handle as manual entry by displaying a single suggestion', async ( searchTerm, searchType ) => { act( () => { render( <LinkControl />, container ); } ); // Search Input UI const searchInput = getURLInput(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: searchTerm }, } ); } ); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); const searchResultElements = getSearchResults(); const firstSearchResultItemHTML = searchResultElements[ 0 ].innerHTML; const expectedResultsLength = 1; expect( searchResultElements ).toHaveLength( expectedResultsLength ); expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( searchTerm ) ); expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( searchType ) ); expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( '按回车件以添加此链接' ) ); } ); } ); } ); 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` act( () => { render( <LinkControl showInitialSuggestions />, container ); } ); await eventLoopTick(); // Search Input UI const searchInput = getURLInput(); const searchResultsWrapper = container.querySelector( '[role="listbox"]' ); const initialSearchResultElements = searchResultsWrapper.querySelectorAll( '[role="option"]' ); const searchResultsLabel = container.querySelector( `#${ searchResultsWrapper.getAttribute( 'aria-labelledby' ) }` ); // Verify input has no value has default suggestions should only show // when this does not have a value expect( searchInput.value ).toBe( '' ); // 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( initialSearchResultElements ).toHaveLength( expectedResultsLength ); expect( searchResultsLabel.innerHTML ).toEqual( 'Recently updated' ); } ); it( 'should not display initial suggestions when input value is present', async () => { // Render with an initial value an ensure that no initial suggestions // are shown. // act( () => { render( <LinkControl showInitialSuggestions value={ fauxEntitySuggestions[ 0 ] } />, container ); } ); 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' ); act( () => { Simulate.click( currentLinkBtn ); } ); const searchInput = getURLInput(); searchInput.focus(); await eventLoopTick(); const searchResultElements = getSearchResults(); // search input is set to the URL value expect( searchInput.value ).toEqual( fauxEntitySuggestions[ 0 ].url ); // it should match any url that's like ?p= and also include a URL option expect( searchResultElements ).toHaveLength( 5 ); expect( searchInput.getAttribute( 'aria-expanded' ) ).toBe( 'true' ); expect( mockFetchSearchSuggestions ).toHaveBeenCalledTimes( 1 ); } ); it( 'should display initial suggestions when input value is manually deleted', async () => { const searchTerm = 'Hello world'; act( () => { render( <LinkControl showInitialSuggestions />, container ); } ); let searchResultElements; let searchInput; // Search Input UI searchInput = getURLInput(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: searchTerm } } ); } ); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); expect( searchInput.value ).toBe( searchTerm ); searchResultElements = getSearchResults(); // delete the text act( () => { Simulate.change( searchInput, { target: { value: '' } } ); } ); await eventLoopTick(); searchResultElements = getSearchResults(); searchInput = getURLInput(); // check the input is empty now expect( searchInput.value ).toBe( '' ); const searchResultLabel = container.querySelector( '.block-editor-link-control__search-results-label' ); expect( searchResultLabel.innerHTML ).toBe( 'Recently updated' ); 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 ) ); act( () => { render( <LinkControl showInitialSuggestions />, container ); } ); await eventLoopTick(); const searchInput = getURLInput(); const searchResultElements = getSearchResults(); const searchResultLabel = container.querySelector( '.block-editor-link-control__search-results-label' ); expect( searchResultLabel ).toBeFalsy(); expect( searchResultElements ).toHaveLength( 0 ); expect( searchInput.getAttribute( 'aria-expanded' ) ).toBe( '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 ) => { 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 ( <LinkControl value={ link } onChange={ ( suggestion ) => { setLink( suggestion ); } } createSuggestion={ createSuggestion } /> ); }; act( () => { render( <LinkControlConsumer />, container ); } ); // Search Input UI const searchInput = container.querySelector( 'input[aria-label="URL"]' ); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: 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 = first( Array.from( searchResultElements ).filter( ( result ) => result.innerHTML.includes( 'Create:' ) ) ); expect( createButton ).not.toBeNull(); expect( createButton.innerHTML ).toEqual( expect.stringContaining( entityNameText ) ); // No need to wait in this test because we control the Promise // resolution manually via the `resolver` reference act( () => { Simulate.click( createButton ); } ); await eventLoopTick(); // Check for loading indicator const loadingIndicator = container.querySelector( '.block-editor-link-control__loading' ); const currentLinkLabel = container.querySelector( '[aria-label="Currently selected"]' ); expect( currentLinkLabel ).toBeNull(); expect( loadingIndicator.innerHTML ).toEqual( expect.stringContaining( 'Creating' ) ); // Resolve the `createSuggestion` promise await act( async () => { resolver( resolvedEntity ); } ); await eventLoopTick(); const currentLink = container.querySelector( '[aria-label="Currently selected"]' ); const currentLinkHTML = currentLink.innerHTML; expect( currentLinkHTML ).toEqual( expect.stringContaining( entityNameText ) ); expect( currentLinkHTML ).toEqual( expect.stringContaining( '/?p=123' ) ); } ); it( 'should allow createSuggestion prop to return a non-Promise value', async () => { 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', } ) } /> ); }; act( () => { render( <LinkControlConsumer />, container ); } ); // Search Input UI const searchInput = container.querySelector( 'input[aria-label="URL"]' ); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: '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 = first( Array.from( searchResultElements ).filter( ( result ) => result.innerHTML.includes( 'Create:' ) ) ); await act( async () => { Simulate.click( createButton ); } ); await eventLoopTick(); const currentLink = container.querySelector( '[aria-label="Currently selected"]' ); const currentLinkHTML = currentLink.innerHTML; expect( currentLinkHTML ).toEqual( expect.stringContaining( 'Some new page to create' ) ); expect( currentLinkHTML ).toEqual( expect.stringContaining( '/?p=123' ) ); } ); it( 'should allow creation of entities via the keyboard', async () => { 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', } ) } /> ); }; act( () => { render( <LinkControlConsumer />, container ); } ); // Search Input UI const searchInput = container.querySelector( 'input[aria-label="URL"]' ); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: 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 = first( Array.from( searchResultElements ).filter( ( result ) => result.innerHTML.includes( 'Create:' ) ) ); // Step down into the search results, highlighting the first result item act( () => { Simulate.keyDown( searchInput, { keyCode: DOWN } ); } ); act( () => { Simulate.keyDown( createButton, { keyCode: ENTER } ); } ); await act( async () => { Simulate.keyDown( searchInput, { keyCode: ENTER } ); } ); await eventLoopTick(); const currentLink = container.querySelector( '[aria-label="Currently selected"]' ); const currentLinkHTML = currentLink.innerHTML; expect( currentLinkHTML ).toEqual( expect.stringContaining( entityNameText ) ); } ); it( 'should allow customisation of button text', async () => { const entityNameText = 'A new page to be created'; const LinkControlConsumer = () => { return ( <LinkControl createSuggestion={ () => {} } createSuggestionButtonText="Custom suggestion text" /> ); }; act( () => { render( <LinkControlConsumer />, container ); } ); // Search Input UI const searchInput = container.querySelector( 'input[aria-label="URL"]' ); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: 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 = first( Array.from( searchResultElements ).filter( ( result ) => result.innerHTML.includes( 'Custom suggestion text' ) ) ); expect( createButton ).not.toBeNull(); } ); 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 ) => { act( () => { render( <LinkControl createSuggestion={ handler } />, container ); } ); // Await the initial suggestions to be fetched await eventLoopTick(); // Search Input UI const searchInput = container.querySelector( 'input[aria-label="URL"]' ); // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. const searchResultElements = container.querySelectorAll( '[role="listbox"] [role="option"]' ); const createButton = first( Array.from( searchResultElements ).filter( ( result ) => result.innerHTML.includes( 'Create:' ) ) ); // Verify input has no value expect( searchInput.value ).toBe( '' ); expect( createButton ).toBeFalsy(); // shouldn't exist! } ); it( 'should not show not show an option to create an entity when input is empty', async () => { act( () => { render( <LinkControl showInitialSuggestions={ true } // should show even if we're not showing initial suggestions createSuggestion={ jest.fn() } />, container ); } ); // Await the initial suggestions to be fetched await eventLoopTick(); // Search Input UI const searchInput = container.querySelector( 'input[aria-label="URL"]' ); // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. const searchResultElements = container.querySelectorAll( '[role="listbox"] [role="option"]' ); const createButton = first( Array.from( searchResultElements ).filter( ( result ) => result.innerHTML.includes( 'New page' ) ) ); // Verify input has no value expect( searchInput.value ).toBe( '' ); expect( createButton ).toBeFalsy(); // shouldn't exist! } ); it.each( [ 'https://www.gechiui.com', 'www.gechiui.com', 'mailto:example123456@www.gechiui.com', 'tel:example123456@www.gechiui.com', '#internal-anchor', ] )( 'should not show option to "Create Page" when text is a form of direct entry (eg: %s)', async ( inputText ) => { act( () => { render( <LinkControl createSuggestion={ jest.fn() } />, container ); } ); // Search Input UI const searchInput = container.querySelector( 'input[aria-label="URL"]' ); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: 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 = first( Array.from( searchResultElements ).filter( ( result ) => result.innerHTML.includes( 'New page' ) ) ); 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 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() ); act( () => { render( <LinkControl createSuggestion={ createSuggestion } />, container ); } ); // Search Input UI searchInput = container.querySelector( 'input[aria-label="URL"]' ); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: 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 = first( Array.from( searchResultElements ).filter( ( result ) => result.innerHTML.includes( 'Create:' ) ) ); await act( async () => { Simulate.click( createButton ); } ); await eventLoopTick(); searchInput = container.querySelector( 'input[aria-label="URL"]' ); // 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/GeChiUI/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 ).not.toBeFalsy(); expect( errorNotice.innerHTML ).toEqual( expect.stringContaining( 'API response returned invalid entity' ) ); // Verify input is repopulated with original search text expect( searchInput ).not.toBeFalsy(); expect( searchInput.value ).toBe( searchText ); // Verify search results are re-shown and create button is available. searchResultElements = container.querySelectorAll( '[role="listbox"] [role="option"]' ); createButton = first( Array.from( searchResultElements ).filter( ( result ) => result.innerHTML.includes( 'New page' ) ) ); } ); } ); } ); describe( 'Selecting links', () => { it( 'should display a selected link corresponding to the provided "currentLink" prop', () => { const selectedLink = first( fauxEntitySuggestions ); const LinkControlConsumer = () => { const [ link ] = useState( selectedLink ); return <LinkControl value={ link } />; }; act( () => { render( <LinkControlConsumer />, container ); } ); // TODO: select by aria role or visible text const currentLink = getCurrentLink(); const currentLinkHTML = currentLink.innerHTML; const currentLinkAnchor = currentLink.querySelector( `[href="${ selectedLink.url }"]` ); expect( currentLinkHTML ).toEqual( expect.stringContaining( selectedLink.title ) ); expect( queryByRole( currentLink, 'button', { name: 'Edit' } ) ).toBeTruthy(); expect( currentLinkAnchor ).not.toBeNull(); } ); it( 'should hide "selected" link UI and display search UI prepopulated with previously selected link title when "Change" button is clicked', () => { const selectedLink = first( fauxEntitySuggestions ); const LinkControlConsumer = () => { const [ link, setLink ] = useState( selectedLink ); return ( <LinkControl value={ link } onChange={ ( suggestion ) => setLink( suggestion ) } /> ); }; act( () => { render( <LinkControlConsumer />, container ); } ); // Required in order to select the button below let currentLinkUI = getCurrentLink(); const currentLinkBtn = currentLinkUI.querySelector( 'button' ); // Simulate searching for a term act( () => { Simulate.click( currentLinkBtn ); } ); const searchInput = getURLInput(); currentLinkUI = getCurrentLink(); // We should be back to showing the search input expect( searchInput ).not.toBeNull(); expect( searchInput.value ).toBe( selectedLink.url ); // prepopulated with previous link's URL expect( currentLinkUI ).toBeNull(); } ); describe( 'Selection using mouse click', () => { it.each( [ [ 'entity', 'hello world', first( fauxEntitySuggestions ) ], // entity search [ 'url', 'https://www.gechiui.com', { id: '1', title: 'https://www.gechiui.com', url: 'https://www.gechiui.com', 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 LinkControlConsumer = () => { const [ link, setLink ] = useState(); return ( <LinkControl value={ link } onChange={ ( suggestion ) => setLink( suggestion ) } /> ); }; act( () => { render( <LinkControlConsumer />, container ); } ); // Search Input UI const searchInput = getURLInput(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: searchTerm }, } ); } ); // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); const searchResultElements = getSearchResults(); const firstSearchSuggestion = first( searchResultElements ); // Simulate selecting the first of the search suggestions act( () => { Simulate.click( firstSearchSuggestion ); } ); const currentLink = container.querySelector( '.block-editor-link-control__search-item.is-current' ); const currentLinkHTML = currentLink.innerHTML; const currentLinkAnchor = currentLink.querySelector( `[href="${ selectedLink.url }"]` ); // Check that this suggestion is now shown as selected expect( currentLinkHTML ).toEqual( expect.stringContaining( selectedLink.title ) ); expect( currentLinkHTML ).toEqual( expect.stringContaining( 'Edit' ) ); expect( currentLinkAnchor ).not.toBeNull(); } ); } ); describe( 'Selection using keyboard', () => { it.each( [ [ 'entity', 'hello world', first( fauxEntitySuggestions ) ], // entity search [ 'url', 'https://www.gechiui.com', { id: '1', title: 'https://www.gechiui.com', url: 'https://www.gechiui.com', 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 LinkControlConsumer = () => { const [ link, setLink ] = useState(); return ( <LinkControl value={ link } onChange={ ( suggestion ) => setLink( suggestion ) } /> ); }; act( () => { render( <LinkControlConsumer />, container ); } ); // Search Input UI const searchInput = getURLInput(); searchInput.focus(); // Simulate searching for a term act( () => { Simulate.change( searchInput, { target: { value: searchTerm }, } ); } ); //fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); // Step down into the search results, highlighting the first result item act( () => { Simulate.keyDown( searchInput, { keyCode: DOWN } ); } ); const searchResultElements = getSearchResults(); const firstSearchSuggestion = first( searchResultElements ); const secondSearchSuggestion = nth( searchResultElements, 1 ); let selectedSearchResultElement = container.querySelector( '[role="option"][aria-selected="true"]' ); // 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 act( () => { Simulate.keyDown( searchInput, { keyCode: DOWN } ); } ); selectedSearchResultElement = container.querySelector( '[role="option"][aria-selected="true"]' ); // We should have highlighted the first item using the keyboard expect( selectedSearchResultElement ).toEqual( secondSearchSuggestion ); // Check we can go back up via up arrow act( () => { Simulate.keyDown( searchInput, { keyCode: UP } ); } ); selectedSearchResultElement = container.querySelector( '[role="option"][aria-selected="true"]' ); // We should be back to highlighting the first search result again expect( selectedSearchResultElement ).toEqual( firstSearchSuggestion ); } // Submit the selected item as the current link act( () => { Simulate.keyDown( searchInput, { keyCode: ENTER } ); } ); // Check that the suggestion selected via is now shown as selected const currentLink = container.querySelector( '.block-editor-link-control__search-item.is-current' ); const currentLinkHTML = currentLink.innerHTML; const currentLinkAnchor = currentLink.querySelector( `[href="${ selectedLink.url }"]` ); // Make sure focus is retained after submission. expect( container.contains( document.activeElement ) ).toBe( true ); expect( currentLinkHTML ).toEqual( expect.stringContaining( selectedLink.title ) ); expect( currentLinkHTML ).toEqual( expect.stringContaining( 'Edit' ) ); expect( currentLinkAnchor ).not.toBeNull(); } ); it( 'should allow selection of initial search results via the keyboard', async () => { act( () => { render( <LinkControl showInitialSuggestions />, container ); } ); await eventLoopTick(); const searchResultsWrapper = container.querySelector( '[role="listbox"]' ); const searchResultsLabel = container.querySelector( `#${ searchResultsWrapper.getAttribute( 'aria-labelledby' ) }` ); expect( searchResultsLabel.innerHTML ).toEqual( 'Recently updated' ); // Search Input UI const searchInput = getURLInput(); // Step down into the search results, highlighting the first result item act( () => { Simulate.keyDown( searchInput, { keyCode: DOWN } ); } ); await eventLoopTick(); const searchResultElements = getSearchResults(); const firstSearchSuggestion = first( searchResultElements ); const secondSearchSuggestion = nth( searchResultElements, 1 ); let selectedSearchResultElement = container.querySelector( '[role="option"][aria-selected="true"]' ); // We should have highlighted the first item using the keyboard expect( selectedSearchResultElement ).toEqual( firstSearchSuggestion ); // Check we can go down again using the down arrow act( () => { Simulate.keyDown( searchInput, { keyCode: DOWN } ); } ); selectedSearchResultElement = container.querySelector( '[role="option"][aria-selected="true"]' ); // We should have highlighted the first item using the keyboard expect( selectedSearchResultElement ).toEqual( secondSearchSuggestion ); // Check we can go back up via up arrow act( () => { Simulate.keyDown( searchInput, { keyCode: UP } ); } ); selectedSearchResultElement = container.querySelector( '[role="option"][aria-selected="true"]' ); // 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 = first( fauxEntitySuggestions ); const expectedSettingText = 'Open in new tab'; const LinkControlConsumer = () => { const [ link ] = useState( selectedLink ); return <LinkControl value={ link } />; }; act( () => { render( <LinkControlConsumer />, container ); } ); const newTabSettingLabel = Array.from( container.querySelectorAll( 'label' ) ).find( ( label ) => label.innerHTML && label.innerHTML.includes( expectedSettingText ) ); expect( newTabSettingLabel ).not.toBeUndefined(); // find() returns "undefined" if not found const newTabSettingLabelForAttr = newTabSettingLabel.getAttribute( 'for' ); const newTabSettingInput = container.querySelector( `#${ newTabSettingLabelForAttr }` ); expect( newTabSettingInput ).not.toBeNull(); expect( newTabSettingInput.checked ).toBe( false ); } ); it( 'should display a setting control with correct default state for each of the custom settings provided', async () => { const selectedLink = first( fauxEntitySuggestions ); const customSettings = [ { id: 'newTab', title: '在新窗口打开', }, { id: 'noFollow', title: 'No follow', }, ]; const customSettingsLabelsText = customSettings.map( ( setting ) => setting.title ); const LinkControlConsumer = () => { const [ link ] = useState( selectedLink ); return ( <LinkControl value={ { ...link, newTab: false, noFollow: true } } settings={ customSettings } /> ); }; act( () => { render( <LinkControlConsumer />, container ); } ); // Grab the elements using user perceivable DOM queries const settingsLegend = Array.from( container.querySelectorAll( 'legend' ) ).find( ( legend ) => legend.innerHTML && legend.innerHTML.includes( '当前选择的链接设置' ) ); const settingsFieldset = settingsLegend.closest( 'fieldset' ); const settingControlsLabels = Array.from( settingsFieldset.querySelectorAll( 'label' ) ); const settingControlsInputs = settingControlsLabe