Skip to content

Commit ac845b2

Browse files
getdavesethrubenstein
authored andcommitted
Improve (and relax) search vs direct URL entry detection in Link Control (WordPress#51011)
* Restirct what matches as potentially being a “url” * Remove direct entry results from coming up in entity search suggestions * Make is-url-like stricter * Add initial tests for isURLLike * Improve code with tests and adding check for TLDs * Simply implementation * Fix tests * Test for only showing URL result when searching for URL like * Improve test criteria for URL-like in tests * Augment tests for entity search
1 parent edaa21c commit ac845b2

File tree

4 files changed

+198
-73
lines changed

4 files changed

+198
-73
lines changed

packages/block-editor/src/components/link-control/is-url-like.js

+40-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* WordPress dependencies
33
*/
4-
import { isURL } from '@wordpress/url';
4+
import { getProtocol, isValidProtocol, isValidFragment } from '@wordpress/url';
55

66
/**
77
* Determines whether a given value could be a URL. Note this does not
@@ -15,6 +15,43 @@ import { isURL } from '@wordpress/url';
1515
* @return {boolean} whether or not the value is potentially a URL.
1616
*/
1717
export default function isURLLike( val ) {
18-
const isInternal = val?.startsWith( '#' );
19-
return isURL( val ) || ( val && val.includes( 'www.' ) ) || isInternal;
18+
const hasSpaces = val.includes( ' ' );
19+
20+
if ( hasSpaces ) {
21+
return false;
22+
}
23+
24+
const protocol = getProtocol( val );
25+
const protocolIsValid = isValidProtocol( protocol );
26+
27+
const mayBeTLD = hasPossibleTLD( val );
28+
29+
const isWWW = val?.startsWith( 'www.' );
30+
31+
const isInternal = val?.startsWith( '#' ) && isValidFragment( val );
32+
33+
return protocolIsValid || isWWW || isInternal || mayBeTLD;
34+
}
35+
36+
/**
37+
* Checks if a given URL has a valid Top-Level Domain (TLD).
38+
*
39+
* @param {string} url - The URL to check.
40+
* @param {number} maxLength - The maximum length of the TLD.
41+
* @return {boolean} Returns true if the URL has a valid TLD, false otherwise.
42+
*/
43+
function hasPossibleTLD( url, maxLength = 6 ) {
44+
// Clean the URL by removing anything after the first occurrence of "?" or "#".
45+
const cleanedURL = url.split( /[?#]/ )[ 0 ];
46+
47+
// Regular expression explanation:
48+
// - (?<=\S) : Positive lookbehind assertion to ensure there is at least one non-whitespace character before the TLD
49+
// - \. : Matches a literal dot (.)
50+
// - [a-zA-Z_]{2,maxLength} : Matches 2 to maxLength letters or underscores, representing the TLD
51+
// - (?:\/|$) : Non-capturing group that matches either a forward slash (/) or the end of the string
52+
const regex = new RegExp(
53+
`(?<=\\S)\\.(?:[a-zA-Z_]{2,${ maxLength }})(?:\\/|$)`
54+
);
55+
56+
return regex.test( cleanedURL );
2057
}

packages/block-editor/src/components/link-control/test/index.js

+82-43
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,7 @@ describe( 'Basic rendering', () => {
196196
within( resultsList ).getAllByRole( 'option' );
197197

198198
expect( searchResultElements ).toHaveLength(
199-
// The fauxEntitySuggestions length plus the 'Press ENTER to add this link' button.
200-
fauxEntitySuggestions.length + 1
199+
fauxEntitySuggestions.length
201200
);
202201

203202
// Step down into the search results, highlighting the first result item.
@@ -440,44 +439,87 @@ describe( 'Searching for a link', () => {
440439
expect( screen.queryByRole( 'presentation' ) ).not.toBeInTheDocument();
441440
} );
442441

443-
it( 'should display only search suggestions when current input value is not URL-like', async () => {
444-
const user = userEvent.setup();
445-
const searchTerm = 'Hello world';
446-
const firstSuggestion = fauxEntitySuggestions[ 0 ];
442+
it.each( [ 'With spaces', 'Uppercase', 'lowercase' ] )(
443+
'should display only search suggestions (and not URL result type) when current input value (e.g. %s) is not URL-like',
444+
async ( searchTerm ) => {
445+
const user = userEvent.setup();
446+
const firstSuggestion = fauxEntitySuggestions[ 0 ];
447447

448-
render( <LinkControl /> );
448+
render( <LinkControl /> );
449449

450-
// Search Input UI.
451-
const searchInput = screen.getByRole( 'combobox', { name: 'URL' } );
450+
// Search Input UI.
451+
const searchInput = screen.getByRole( 'combobox', { name: 'URL' } );
452452

453-
// Simulate searching for a term.
454-
await user.type( searchInput, searchTerm );
453+
// Simulate searching for a term.
454+
await user.type( searchInput, searchTerm );
455455

456-
const searchResultElements = within(
457-
await screen.findByRole( 'listbox', {
458-
name: /Search results for.*/,
459-
} )
460-
).getAllByRole( 'option' );
456+
const searchResultElements = within(
457+
await screen.findByRole( 'listbox', {
458+
name: /Search results for.*/,
459+
} )
460+
).getAllByRole( 'option' );
461461

462-
expect( searchResultElements ).toHaveLength(
463-
fauxEntitySuggestions.length
464-
);
462+
expect( searchResultElements ).toHaveLength(
463+
fauxEntitySuggestions.length
464+
);
465465

466-
expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' );
466+
expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' );
467467

468-
// Check that a search suggestion shows up corresponding to the data.
469-
expect( searchResultElements[ 0 ] ).toHaveTextContent(
470-
firstSuggestion.title
471-
);
472-
expect( searchResultElements[ 0 ] ).toHaveTextContent(
473-
firstSuggestion.type
474-
);
468+
// Check that a search suggestion shows up corresponding to the data.
469+
expect( searchResultElements[ 0 ] ).toHaveTextContent(
470+
firstSuggestion.title
471+
);
472+
expect( searchResultElements[ 0 ] ).toHaveTextContent(
473+
firstSuggestion.type
474+
);
475475

476-
// The fallback URL suggestion should not be shown when input is not URL-like.
477-
expect(
478-
searchResultElements[ searchResultElements.length - 1 ]
479-
).not.toHaveTextContent( 'URL' );
480-
} );
476+
// The fallback URL suggestion should not be shown when input is not URL-like.
477+
expect(
478+
searchResultElements[ searchResultElements.length - 1 ]
479+
).not.toHaveTextContent( 'URL' );
480+
}
481+
);
482+
483+
it.each( [
484+
[ 'https://wordpress.org', 'URL' ],
485+
[ 'http://wordpress.org', 'URL' ],
486+
[ 'www.wordpress.org', 'URL' ],
487+
[ 'wordpress.org', 'URL' ],
488+
[ 'ftp://wordpress.org', 'URL' ],
489+
[ 'mailto:hello@wordpress.org', 'mailto' ],
490+
[ 'tel:123456789', 'tel' ],
491+
[ '#internal', 'internal' ],
492+
] )(
493+
'should display only URL result when current input value is URL-like (e.g. %s)',
494+
async ( searchTerm, type ) => {
495+
const user = userEvent.setup();
496+
497+
render( <LinkControl /> );
498+
499+
// Search Input UI.
500+
const searchInput = screen.getByRole( 'combobox', { name: 'URL' } );
501+
502+
// Simulate searching for a term.
503+
await user.type( searchInput, searchTerm );
504+
505+
const searchResultElement = within(
506+
await screen.findByRole( 'listbox', {
507+
name: /Search results for.*/,
508+
} )
509+
).getByRole( 'option' );
510+
511+
expect( searchResultElement ).toBeInTheDocument();
512+
513+
// Should only be the `URL` suggestion.
514+
expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' );
515+
516+
expect( searchResultElement ).toHaveTextContent( searchTerm );
517+
expect( searchResultElement ).toHaveTextContent( type );
518+
expect( searchResultElement ).toHaveTextContent(
519+
'Press ENTER to add this link'
520+
);
521+
}
522+
);
481523

482524
it( 'should trim search term', async () => {
483525
const user = userEvent.setup();
@@ -504,8 +546,7 @@ describe( 'Searching for a link', () => {
504546
.flat()
505547
.filter( Boolean );
506548

507-
// Given we're mocking out the results we should always have 4 mark elements.
508-
expect( searchResultTextHighlightElements ).toHaveLength( 4 );
549+
expect( searchResultTextHighlightElements ).toHaveLength( 3 );
509550

510551
// Make sure there are no `mark` elements which contain anything other
511552
// than the trimmed search term (ie: no whitespace).
@@ -565,16 +606,15 @@ describe( 'Searching for a link', () => {
565606
const lastSearchResultItem =
566607
searchResultElements[ searchResultElements.length - 1 ];
567608

568-
// We should see a search result for each of the expect search suggestions
569-
// plus 1 additional one for the fallback URL suggestion.
609+
// We should see a search result for each of the expect search suggestions.
570610
expect( searchResultElements ).toHaveLength(
571-
fauxEntitySuggestions.length + 1
611+
fauxEntitySuggestions.length
572612
);
573613

574-
// The last item should be a URL search suggestion.
575-
expect( lastSearchResultItem ).toHaveTextContent( searchTerm );
576-
expect( lastSearchResultItem ).toHaveTextContent( 'URL' );
577-
expect( lastSearchResultItem ).toHaveTextContent(
614+
// The URL search suggestion should not exist.
615+
expect( lastSearchResultItem ).not.toHaveTextContent( searchTerm );
616+
expect( lastSearchResultItem ).not.toHaveTextContent( 'URL' );
617+
expect( lastSearchResultItem ).not.toHaveTextContent(
578618
'Press ENTER to add this link'
579619
);
580620
}
@@ -964,8 +1004,7 @@ describe( 'Default search suggestions', () => {
9641004
} )
9651005
).getAllByRole( 'option' );
9661006

967-
// It should match any url that's like ?p= and also include a URL option.
968-
expect( searchResultElements ).toHaveLength( 5 );
1007+
expect( searchResultElements ).toHaveLength( 4 );
9691008

9701009
expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' );
9711010

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Internal dependencies
3+
*/
4+
import isURLLike from '../is-url-like';
5+
6+
describe( 'isURLLike', () => {
7+
it.each( [ 'https://wordpress.org', 'http://wordpress.org' ] )(
8+
'returns true for a string that starts with an http(s) protocol',
9+
( testString ) => {
10+
expect( isURLLike( testString ) ).toBe( true );
11+
}
12+
);
13+
14+
it.each( [
15+
'hello world',
16+
'https:// has spaces even though starts with protocol',
17+
'www. wordpress . org',
18+
] )(
19+
'returns false for any string with spaces (e.g. "%s")',
20+
( testString ) => {
21+
expect( isURLLike( testString ) ).toBe( false );
22+
}
23+
);
24+
25+
it( 'returns false for a string without a protocol or a TLD', () => {
26+
expect( isURLLike( 'somedirectentryhere' ) ).toBe( false );
27+
} );
28+
29+
it( 'returns true for a string beginning with www.', () => {
30+
expect( isURLLike( 'www.wordpress.org' ) ).toBe( true );
31+
} );
32+
33+
it.each( [ 'mailto:test@wordpress.org', 'tel:123456' ] )(
34+
'returns true for common protocols',
35+
( testString ) => {
36+
expect( isURLLike( testString ) ).toBe( true );
37+
}
38+
);
39+
40+
it( 'returns true for internal anchor ("hash") links.', () => {
41+
expect( isURLLike( '#someinternallink' ) ).toBe( true );
42+
} );
43+
44+
// use .each to test multiple cases
45+
it.each( [
46+
[ true, 'http://example.com' ],
47+
[ true, 'https://test.co.uk?query=param' ],
48+
[ true, 'ftp://openai.ai?param=value#section' ],
49+
[ true, 'example.com' ],
50+
[ true, 'http://example.com?query=param#section' ],
51+
[ true, 'https://test.co.uk/some/path' ],
52+
[ true, 'ftp://openai.ai/some/path' ],
53+
[ true, 'example.org/some/path' ],
54+
[ true, 'example_test.tld' ],
55+
[ true, 'example_test.com' ],
56+
[ false, 'example' ],
57+
[ false, '.com' ],
58+
[ true, '_test.com' ],
59+
[ true, 'http://example_test.com' ],
60+
] )(
61+
'returns %s when testing against string "%s" for a valid TLD',
62+
( expected, testString ) => {
63+
expect( isURLLike( testString ) ).toBe( expected );
64+
}
65+
);
66+
} );

packages/block-editor/src/components/link-control/use-search-handler.js

+10-27
Original file line numberDiff line numberDiff line change
@@ -51,46 +51,23 @@ const handleEntitySearch = async (
5151
val,
5252
suggestionsQuery,
5353
fetchSearchSuggestions,
54-
directEntryHandler,
5554
withCreateSuggestion,
56-
withURLSuggestion,
5755
pageOnFront
5856
) => {
5957
const { isInitialSuggestions } = suggestionsQuery;
60-
let resultsIncludeFrontPage = false;
6158

62-
let results = await Promise.all( [
63-
fetchSearchSuggestions( val, suggestionsQuery ),
64-
directEntryHandler( val ),
65-
] );
59+
const results = await fetchSearchSuggestions( val, suggestionsQuery );
6660

6761
// Identify front page and update type to match.
68-
results[ 0 ] = results[ 0 ].map( ( result ) => {
62+
results.map( ( result ) => {
6963
if ( Number( result.id ) === pageOnFront ) {
70-
resultsIncludeFrontPage = true;
7164
result.isFrontPage = true;
7265
return result;
7366
}
7467

7568
return result;
7669
} );
7770

78-
const couldBeURL = ! val.includes( ' ' );
79-
80-
// If it's potentially a URL search then concat on a URL search suggestion
81-
// just for good measure. That way once the actual results run out we always
82-
// have a URL option to fallback on.
83-
if (
84-
! resultsIncludeFrontPage &&
85-
couldBeURL &&
86-
withURLSuggestion &&
87-
! isInitialSuggestions
88-
) {
89-
results = results[ 0 ].concat( results[ 1 ] );
90-
} else {
91-
results = results[ 0 ];
92-
}
93-
9471
// If displaying initial suggestions just return plain results.
9572
if ( isInitialSuggestions ) {
9673
return results;
@@ -150,12 +127,18 @@ export default function useSearchHandler(
150127
val,
151128
{ ...suggestionsQuery, isInitialSuggestions },
152129
fetchSearchSuggestions,
153-
directEntryHandler,
154130
withCreateSuggestion,
155131
withURLSuggestion,
156132
pageOnFront
157133
);
158134
},
159-
[ directEntryHandler, fetchSearchSuggestions, withCreateSuggestion ]
135+
[
136+
directEntryHandler,
137+
fetchSearchSuggestions,
138+
pageOnFront,
139+
suggestionsQuery,
140+
withCreateSuggestion,
141+
withURLSuggestion,
142+
]
160143
);
161144
}

0 commit comments

Comments
 (0)