diff --git a/e2e/tests/link-control.spec.ts b/e2e/tests/link-control.spec.ts new file mode 100644 index 0000000000..5c8a856980 --- /dev/null +++ b/e2e/tests/link-control.spec.ts @@ -0,0 +1,104 @@ +import { + test, + expect, + waitForBlockEditor, +} from 'e2e/test-utils' + +test.describe( 'Link control URL input', () => { + const createdPostIds: Array = [] + + test.afterEach( async ( { requestUtils } ) => { + for ( const id of createdPostIds.splice( 0 ) ) { + await requestUtils.deletePost( id ).catch( () => undefined ) + } + } ) + + test( 'accepts URLs, shortcodes, and other non-URL values', async ( { + page, + admin, + editor, + stackable, + } ) => { + await admin.createNewPost( { title: 'Link Control URL Input' } ) + await editor.saveDraft() + const postQuery = new URL( editor.page.url() ).search + const postId = new URLSearchParams( postQuery ).get( 'post' ) + if ( postId ) { + createdPostIds.push( postId ) + } + + await stackable.dismissToursAndNotices() + await waitForBlockEditor( editor ) + + await editor.insertBlock( { name: 'stackable/button-group' } ) + await stackable.pickDefaultLayout( editor ) + await stackable.selectBlockByName( editor, 'stackable/button' ) + + await stackable.openInspectorTab( 'Style' ) + + const inspector = page.getByRole( 'region', { name: 'Editor settings' } ) + const linkPanel = inspector.locator( '.ugb-toggle-panel-body.ugb-panel--link' ) + await expect( linkPanel ).toBeVisible() + if ( ! ( await linkPanel.getAttribute( 'class' ) || '' ).includes( 'is-opened' ) ) { + await linkPanel.locator( '.components-panel__body-toggle' ).click() + } + await expect( linkPanel ).toHaveClass( /is-opened/ ) + + const linkControl = linkPanel.locator( '.stk-link-control' ).filter( { + has: page.locator( '.stk-control-label', { hasText: /Link \/ URL/ } ), + } ) + const input = linkControl.getByRole( 'combobox', { name: 'URL' } ) + await expect( input ).toBeVisible() + + const buttonBlock = editor.canvas.locator( '[data-type="stackable/button"]' ).first() + const clientId = await buttonBlock.getAttribute( 'data-block' ) + + const setLinkValue = async ( value: string ) => { + await input.click() + await input.fill( value ) + await page.keyboard.press( 'Escape' ) + await input.blur() + } + + await setLinkValue( 'https://example.com' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( 'https://example.com' ) + await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' ) + + await setLinkValue( 'example.com' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( 'https://example.com' ) + + await setLinkValue( '[my_shortcode]' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( '[my_shortcode]' ) + await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' ) + + await setLinkValue( '!#stk_dynamic/current-page/url!#' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( '!#stk_dynamic/current-page/url!#' ) + await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' ) + + await setLinkValue( '{{permalink}}' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( '{{permalink}}' ) + await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' ) + + await setLinkValue( 'https://' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( 'https://' ) + await expect( linkControl ).toContainText( 'Please enter a valid URL.' ) + } ) +} ) diff --git a/src/components/link-control/__test__/validate.test.js b/src/components/link-control/__test__/validate.test.js new file mode 100644 index 0000000000..e2bb1377e6 --- /dev/null +++ b/src/components/link-control/__test__/validate.test.js @@ -0,0 +1,71 @@ +/** + * Internal dependencies + */ +import { + isPassThroughLinkValue, + isUrlLike, + isValidLinkValue, + normalizeLinkValue, +} from '../validate' + +describe( 'link-control validation', () => { + it( 'treats empty values as pass-through', () => { + expect( isPassThroughLinkValue( '' ) ).toBe( true ) + expect( isPassThroughLinkValue( ' ' ) ).toBe( true ) + expect( isPassThroughLinkValue( undefined ) ).toBe( true ) + expect( isValidLinkValue( '' ) ).toBe( true ) + } ) + + it( 'allows shortcodes without treating them as URLs', () => { + expect( isPassThroughLinkValue( '[my_shortcode]' ) ).toBe( true ) + expect( isPassThroughLinkValue( '[contact-form-7 id="1"]' ) ).toBe( true ) + expect( isPassThroughLinkValue( '[site.url]' ) ).toBe( true ) + expect( isValidLinkValue( '[my_shortcode]' ) ).toBe( true ) + expect( normalizeLinkValue( '[my_shortcode]' ) ).toBe( '[my_shortcode]' ) + } ) + + it( 'allows dynamic content tokens', () => { + const token = '!#stk_dynamic/current-page/url!#' + expect( isPassThroughLinkValue( token ) ).toBe( true ) + expect( isValidLinkValue( token ) ).toBe( true ) + expect( normalizeLinkValue( token ) ).toBe( token ) + } ) + + it( 'allows other non-URL strings', () => { + expect( isPassThroughLinkValue( '{{permalink}}' ) ).toBe( true ) + expect( isPassThroughLinkValue( '%post_url%' ) ).toBe( true ) + expect( isPassThroughLinkValue( 'hello' ) ).toBe( true ) + expect( isValidLinkValue( 'hello' ) ).toBe( true ) + expect( isUrlLike( 'hello' ) ).toBe( false ) + } ) + + it( 'recognizes URL-like values', () => { + expect( isUrlLike( 'https://example.com' ) ).toBe( true ) + expect( isUrlLike( 'example.com' ) ).toBe( true ) + expect( isUrlLike( 'www.example.com' ) ).toBe( true ) + expect( isUrlLike( '#section' ) ).toBe( true ) + expect( isUrlLike( '/about' ) ).toBe( true ) + expect( isUrlLike( 'mailto:hi@example.com' ) ).toBe( true ) + } ) + + it( 'accepts valid URLs, anchors, and relative paths', () => { + expect( isValidLinkValue( 'https://example.com' ) ).toBe( true ) + expect( isValidLinkValue( '#section' ) ).toBe( true ) + expect( isValidLinkValue( '/about' ) ).toBe( true ) + expect( isValidLinkValue( '../parent' ) ).toBe( true ) + expect( isValidLinkValue( 'mailto:hi@example.com' ) ).toBe( true ) + } ) + + it( 'rejects incomplete URL-like values', () => { + expect( isValidLinkValue( 'https://' ) ).toBe( false ) + expect( isValidLinkValue( 'http://' ) ).toBe( false ) + } ) + + it( 'prepends https to bare domains on normalize', () => { + expect( normalizeLinkValue( 'example.com' ) ).toBe( 'https://example.com' ) + expect( normalizeLinkValue( ' example.com ' ) ).toBe( 'https://example.com' ) + expect( normalizeLinkValue( 'https://example.com' ) ).toBe( 'https://example.com' ) + expect( normalizeLinkValue( '#section' ) ).toBe( '#section' ) + expect( normalizeLinkValue( '/about' ) ).toBe( '/about' ) + } ) +} ) diff --git a/src/components/link-control/editor.scss b/src/components/link-control/editor.scss index 59267347ca..9bead0ff58 100644 --- a/src/components/link-control/editor.scss +++ b/src/components/link-control/editor.scss @@ -1,81 +1,38 @@ .stk-link-control__input { // Adjust width to ensure reset button is visible. width: calc(100% - 32px); + min-width: 0; - > .block-editor-link-control { - min-width: auto; + > .block-editor-url-input { + min-width: 0; width: 100%; .components-base-control { - min-width: auto; + min-width: 0; + width: 100%; } - .components-input-base { - height: 30px; - } - - .block-editor-link-control__search-input-wrapper { - margin: 0; - } - - .block-editor-link-control__field { + .components-base-control__field { margin: 0; } - - .block-editor-link-control__search-item-header { - overflow: hidden; - white-space: nowrap; - } - } - .block-editor-url-input__input { + .block-editor-url-input__input, + .components-input-control__input { margin: 0 !important; width: 100% !important; height: 30px !important; padding: 6px 8px !important; } - .block-editor-link-control__search-actions { - display: none; - } - .block-editor-link-control__search-results-wrapper { - margin-bottom: 24px !important; - margin-top: 0 !important; - .block-editor-link-control__search-results { - margin: 0; - } - } - .block-editor-link-control__search-item { - flex-wrap: wrap; - } - .block-editor-link-control__search-item.is-current { - margin-top: -2px; - padding: 0 !important; - } - .block-editor-link-control__search-item-details { - max-width: 140px !important; - overflow: hidden; - } +} - .block-editor-link-control__search-item-icon { - display: none; - } - - .block-editor-link-control__search-enter { - position: absolute; - top: 0; - right: 3px; - button:hover { - box-shadow: none !important; - } +.stk-link-control--invalid { + .components-base-control__help { + color: #cc1818; } } -// Adjust the location of the dynamic and reset buttons since our control is taller. .stk-link-control { - .block-editor-link-control__field { - margin: auto; - } .stk-dynamic-content-control { display: flex; align-items: center; diff --git a/src/components/link-control/index.js b/src/components/link-control/index.js index 3f61d874e9..40e504fe6e 100644 --- a/src/components/link-control/index.js +++ b/src/components/link-control/index.js @@ -2,14 +2,12 @@ * External dependencies */ import classnames from 'classnames' +import { i18n } from 'stackable' /** * WordPress dependencies */ -import { - __experimentalLinkControl as _LinkControl, // eslint-disable-line @wordpress/no-unsafe-wp-apis -} from '@wordpress/block-editor' -import { BaseControl as _BaseControl } from '@wordpress/components' +import { URLInput } from '@wordpress/block-editor' import { __ } from '@wordpress/i18n' /** @@ -19,17 +17,25 @@ import DynamicContentControl, { useDynamicContentControlProps } from '../dynamic import AdvancedControl, { extractControlProps } from '../base-control2' import { useControlHandlers } from '../base-control2/hooks' import { ResetButton } from '../base-control2/reset-button' +import { + isValidLinkValue, + normalizeLinkValue, +} from './validate' const LinkControl = props => { const [ _value, _onChange ] = useControlHandlers( props.attribute, props.responsive, props.hover, props.valueCallback, props.changeCallback ) const [ propsToPass, controlProps ] = extractControlProps( props ) const { isDynamic, + showSuggestions, ...inputProps } = propsToPass const value = typeof props.value === 'undefined' ? _value : props.value const onChange = typeof props.onChange === 'undefined' ? _onChange : props.onChange + const urlError = value && ! isValidLinkValue( value ) + ? __( 'Please enter a valid URL.', i18n ) + : '' const dynamicContentProps = useDynamicContentControlProps( { value, onChange } ) @@ -38,22 +44,37 @@ const LinkControl = props => { props.className, ], { 'stk--has-value': value, + 'stk-link-control--invalid': urlError, } ) + const handleBlur = () => { + const normalized = normalizeLinkValue( value ) + if ( normalized !== value ) { + onChange( normalized ) + } + } + return ( - + -
- <_LinkControl +
+ onChange( url ) } - settings={ [] } // The Url only. - forceIsEditingLink={ ! value } + value={ value } + onChange={ onChange } + disableSuggestions={ ! showSuggestions } + autoFocus={ false } // eslint-disable-line />
diff --git a/src/components/link-control/validate.js b/src/components/link-control/validate.js new file mode 100644 index 0000000000..2c32388777 --- /dev/null +++ b/src/components/link-control/validate.js @@ -0,0 +1,157 @@ +/** + * Link values that should skip Gutenberg-style URL validation. + * + * Stackable link fields accept shortcodes, dynamic content tokens, and other + * non-URL strings that Gutenberg's LinkControl now rejects. + */ + +/** + * WordPress dependencies + */ +import { + getProtocol, + isValidFragment, + isValidProtocol, + prependHTTP, +} from '@wordpress/url' + +const SHORTCODE_VALUE = /^\s*\[[^\]]+\]/ + +const prependHTTPS = url => { + const withProtocol = prependHTTP( url ) + return withProtocol.startsWith( 'http://' ) + ? `https://${ withProtocol.slice( 'http://'.length ) }` + : withProtocol +} + +const hasDynamicContentToken = value => + value.includes( '!#stk_dynamic' ) || value.includes( 'data-stk-dynamic' ) + +const isHashLink = value => value.startsWith( '#' ) && isValidFragment( value ) + +const isRelativePath = value => + value.startsWith( '/' ) || + value.startsWith( './' ) || + value.startsWith( '../' ) + +/** + * True when the string looks like a domain with a TLD, e.g. `example.com`. + * + * Mirrors Gutenberg's LinkControl heuristic. + * + * @param {string} url + * @param {number} maxLength + * @return {boolean} Whether the value has a possible TLD. + */ +const hasPossibleTLD = ( url, maxLength = 6 ) => { + const cleanedURL = url.split( /[?#]/ )[ 0 ] + return new RegExp( `\\S\\.[a-zA-Z_]{2,${ maxLength }}(?:\\/|$)` ).test( cleanedURL ) +} + +/** + * True when the value should be treated as a URL rather than a free-form string. + * + * @param {string} value + * @return {boolean} Whether the value looks like a URL. + */ +export const isUrlLike = value => { + if ( ! value || value.includes( ' ' ) ) { + return false + } + + const protocol = getProtocol( value ) + + return ( + isValidProtocol( protocol ) || + value.startsWith( 'www.' ) || + isHashLink( value ) || + hasPossibleTLD( value ) || + isRelativePath( value ) + ) +} + +/** + * True when URL validation should not run. + * + * @param {string} value + * @return {boolean} Whether the value is a shortcode, dynamic token, or other non-URL. + */ +export const isPassThroughLinkValue = value => { + if ( ! value || typeof value !== 'string' ) { + return true + } + + const trimmed = value.trim() + if ( ! trimmed ) { + return true + } + + if ( hasDynamicContentToken( trimmed ) ) { + return true + } + + if ( SHORTCODE_VALUE.test( trimmed ) ) { + return true + } + + return ! isUrlLike( trimmed ) +} + +/** + * True when a URL-like value can be parsed as a URL. + * + * @param {string} value + * @return {boolean} Whether the URL-like value is valid. + */ +export const isValidUrlLikeValue = value => { + if ( isHashLink( value ) || isRelativePath( value ) ) { + return true + } + + try { + // eslint-disable-next-line no-new + new URL( prependHTTPS( value ) ) + return true + } catch { + return false + } +} + +/** + * True when the link field may keep this value. + * + * @param {string} value + * @return {boolean} Whether the value is allowed. + */ +export const isValidLinkValue = value => { + if ( isPassThroughLinkValue( value ) ) { + return true + } + + return isValidUrlLikeValue( value.trim() ) +} + +/** + * Normalize URL-like values by trimming and prepending https when needed. + * Shortcodes, dynamic content, and other non-URLs are left as entered. + * + * @param {string} value + * @return {string} Normalized value. + */ +export const normalizeLinkValue = value => { + if ( typeof value !== 'string' ) { + return value + } + + const trimmed = value.trim() + if ( isPassThroughLinkValue( trimmed ) ) { + return trimmed + } + + if ( isHashLink( trimmed ) || isRelativePath( trimmed ) ) { + return trimmed + } + + const normalized = prependHTTPS( trimmed ) + return isValidUrlLikeValue( normalized ) ? normalized : value +}