diff --git a/packages/format-library/src/link/index.js b/packages/format-library/src/link/index.js index 7455c75b55809..fdb62566ad99e 100644 --- a/packages/format-library/src/link/index.js +++ b/packages/format-library/src/link/index.js @@ -92,6 +92,7 @@ export const link = { render() { const { isActive, activeAttributes, value, onChange } = this.props; + const { addingLink } = this.state; return ( <> @@ -123,15 +124,16 @@ export const link = { shortcutType="primary" shortcutCharacter="k" /> } - + { ( isActive || addingLink ) && ( + + ) } ); } diff --git a/packages/format-library/src/link/inline.js b/packages/format-library/src/link/inline.js index 34c05ad432c0c..673f9d7667b6e 100644 --- a/packages/format-library/src/link/inline.js +++ b/packages/format-library/src/link/inline.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Component, createRef, useMemo } from '@wordpress/element'; +import { useRef, useState, useMemo, useEffect } from '@wordpress/element'; import { ToggleControl, withSpokenMessages, @@ -26,11 +26,7 @@ import { createLinkFormat, isValidHref } from './utils'; const stopKeyPropagation = ( event ) => event.stopPropagation(); -function isShowingInput( props, state ) { - return props.addingLink || state.editLink; -} - -const URLPopoverAtLink = ( { isActive, addingLink, value, ...props } ) => { +export const URLPopoverAtLink = ( { isActive, addingLink, value, ...props } ) => { const anchorRef = useMemo( () => { const selection = window.getSelection(); @@ -63,84 +59,62 @@ const URLPopoverAtLink = ( { isActive, addingLink, value, ...props } ) => { return ; }; -class InlineLinkUI extends Component { - constructor() { - super( ...arguments ); - - this.editLink = this.editLink.bind( this ); - this.submitLink = this.submitLink.bind( this ); - this.onKeyDown = this.onKeyDown.bind( this ); - this.onChangeInputValue = this.onChangeInputValue.bind( this ); - this.setLinkTarget = this.setLinkTarget.bind( this ); - this.onFocusOutside = this.onFocusOutside.bind( this ); - this.resetState = this.resetState.bind( this ); - this.autocompleteRef = createRef(); - - this.state = { - opensInNewWindow: false, - inputValue: '', - }; - } - - static getDerivedStateFromProps( props, state ) { - const { activeAttributes: { url, target } } = props; - const opensInNewWindow = target === '_blank'; - - if ( ! isShowingInput( props, state ) ) { - const update = {}; - if ( url !== state.inputValue ) { - update.inputValue = url; - } - - if ( opensInNewWindow !== state.opensInNewWindow ) { - update.opensInNewWindow = opensInNewWindow; - } - return Object.keys( update ).length ? update : null; +function InlineLinkUI( { + isActive, + activeAttributes, + addingLink, + value, + onChange, + stopAddingLink, + speak, +} ) { + const [ opensInNewWindow, setOpensInNewWindow ] = useState( false ); + const [ inputValue, setInputValue ] = useState( '' ); + const [ isEditingLink, setIsEditingLink ] = useState( false ); + const autocompleteRef = useRef(); + + const { url, target } = activeAttributes; + const isShowingInput = addingLink || isEditingLink; + + useEffect( () => { + if ( ! isShowingInput ) { + setInputValue( url ); + setOpensInNewWindow( target === '_blank' ); } + } ); - return null; - } - - onKeyDown( event ) { + function onKeyDown( event ) { if ( [ LEFT, DOWN, RIGHT, UP, BACKSPACE, ENTER ].indexOf( event.keyCode ) > -1 ) { // Stop the key event from propagating up to ObserveTyping.startTypingInTextField. event.stopPropagation(); } } - onChangeInputValue( inputValue ) { - this.setState( { inputValue } ); - } - - setLinkTarget( opensInNewWindow ) { - const { activeAttributes: { url = '' }, value, onChange } = this.props; - - this.setState( { opensInNewWindow } ); + function setLinkTarget( nextOpensInNewWindow ) { + setOpensInNewWindow( nextOpensInNewWindow ); // Apply now if URL is not being edited. - if ( ! isShowingInput( this.props, this.state ) ) { + if ( ! isShowingInput ) { const selectedText = getTextContent( slice( value ) ); onChange( applyFormat( value, createLinkFormat( { url, - opensInNewWindow, + opensInNewWindow: nextOpensInNewWindow, text: selectedText, } ) ) ); } } - editLink( event ) { - this.setState( { editLink: true } ); + function editLink( event ) { + setIsEditingLink( true ); event.preventDefault(); } - submitLink( event ) { - const { isActive, value, onChange, speak } = this.props; - const { inputValue, opensInNewWindow } = this.state; - const url = prependHTTP( inputValue ); + function submitLink( event ) { + const nextURL = prependHTTP( inputValue ); const selectedText = getTextContent( slice( value ) ); const format = createLinkFormat( { - url, + url: nextURL, opensInNewWindow, text: selectedText, } ); @@ -148,15 +122,15 @@ class InlineLinkUI extends Component { event.preventDefault(); if ( isCollapsed( value ) && ! isActive ) { - const toInsert = applyFormat( create( { text: url } ), format, 0, url.length ); + const toInsert = applyFormat( create( { text: nextURL } ), format, 0, nextURL.length ); onChange( insert( value, toInsert ) ); } else { onChange( applyFormat( value, format ) ); } - this.resetState(); + resetState(); - if ( ! isValidHref( url ) ) { + if ( ! isValidHref( nextURL ) ) { speak( __( 'Warning: the link has been inserted but may have errors. Please test it.' ), 'assertive' ); } else if ( isActive ) { speak( __( 'Link edited.' ), 'assertive' ); @@ -165,72 +139,61 @@ class InlineLinkUI extends Component { } } - onFocusOutside() { + function onFocusOutside() { // The autocomplete suggestions list renders in a separate popover (in a portal), // so onFocusOutside fails to detect that a click on a suggestion occurred in the // LinkContainer. Detect clicks on autocomplete suggestions using a ref here, and // return to avoid the popover being closed. - const autocompleteElement = this.autocompleteRef.current; + const autocompleteElement = autocompleteRef.current; if ( autocompleteElement && autocompleteElement.contains( document.activeElement ) ) { return; } - this.resetState(); + resetState(); } - resetState() { - this.props.stopAddingLink(); - this.setState( { editLink: false } ); + function resetState() { + stopAddingLink(); + setIsEditingLink( false ); } - render() { - const { isActive, activeAttributes: { url }, addingLink, value } = this.props; - - if ( ! isActive && ! addingLink ) { - return null; - } - - const { inputValue, opensInNewWindow } = this.state; - const showInput = isShowingInput( this.props, this.state ); - - return ( - ( - - ) } - > - { showInput ? ( - - ) : ( - - ) } - - ); - } + return ( + ( + + ) } + > + { isShowingInput ? ( + + ) : ( + + ) } + + ); } export default withSpokenMessages( InlineLinkUI ); diff --git a/packages/format-library/src/link/test/inline.js b/packages/format-library/src/link/test/inline.js index fa5201992c10b..18928a94cbc8f 100644 --- a/packages/format-library/src/link/test/inline.js +++ b/packages/format-library/src/link/test/inline.js @@ -1,38 +1,70 @@ /** - * Internal dependencies + * External dependencies */ -import InlineLinkUI from '../inline'; +import TestRenderer, { act } from 'react-test-renderer'; + /** - * External dependencies + * WordPress dependencies */ -import { shallow } from 'enzyme'; +import { create } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import InlineLinkUI, { URLPopoverAtLink } from '../inline'; describe( 'InlineLinkUI', () => { - it( 'InlineLinkUI renders', () => { - const wrapper = shallow( - - ); - expect( wrapper ).toBeTruthy(); + const defaultProps = { + value: create( { text: '' } ), + activeAttributes: {}, + }; + + // TODO: At the time of writing, JSDOM has just implemented Selection APIs, + // but they are not yet published in a publicly-available release. This + // `window.getSelection` assignment should be non-harmful but also redundant + // once this becomes available. In other words, once the next JSDOM feature + // release is published, this lifecycle code should be able to be removed. + // + // See: https://github.com/jsdom/jsdom/pull/2719 + let originalWindowGetSelection; + beforeAll( () => { + originalWindowGetSelection = window.getSelection; + window.getSelection = () => ( { rangeCount: 0 } ); + } ); + + afterAll( () => { + window.getSelection = originalWindowGetSelection; } ); - it( 'should set state.opensInNewWindow to false by default', () => { - const wrapper = shallow( - - ).dive(); + it( 'should set "Opens in New Tab" toggle to unchecked by default', () => { + let renderer; + act( () => { + renderer = TestRenderer.create( ); + } ); - expect( wrapper.state( 'opensInNewWindow' ) ).toEqual( false ); + const openInNewTabToggle = renderer.root.findByType( URLPopoverAtLink ).props.renderSettings(); + expect( openInNewTabToggle.props.checked ).toBe( false ); } ); - it( 'should set state.opensInNewWindow to true if props.activeAttributes.target is _blank', () => { - const givenProps = { - addingLink: false, - activeAttributes: { url: 'http://www.google.com', target: '_blank' }, - }; - - const wrapper = shallow( - - ).dive(); - wrapper.setProps( givenProps ); - expect( wrapper.state( 'opensInNewWindow' ) ).toEqual( true ); + it( 'should set "Opens in New Tab" toggle to checked if props.activeAttributes.target is _blank', () => { + let renderer; + act( () => { + renderer = TestRenderer.create( ); + } ); + + act( () => { + renderer.update( + + ); + } ); + + const openInNewTabToggle = renderer.root.findByType( URLPopoverAtLink ).props.renderSettings(); + expect( openInNewTabToggle.props.checked ).toBe( true ); } ); } );