Skip to content

Commit

Permalink
Refactored PostTextEditor to use React Hook (#23897)
Browse files Browse the repository at this point in the history
* Refactored PostTextEditor to use React Hook

* use useInstanceId hook instead of withInstanceId

* Replaced withSelect and withDispatch with useSelect and useDispatch

* Replaced withSelect and withDispatch with useSelect and useDispatch

* Remove external prop support

* Refactored edit function to onChange

* Removed onPersist function

Co-authored-by: Janvo Aldred <[email protected]>
  • Loading branch information
javidalkaruzi and Janvo Aldred authored Aug 10, 2020
1 parent 4719212 commit 39d5086
Show file tree
Hide file tree
Showing 2 changed files with 158 additions and 140 deletions.
124 changes: 49 additions & 75 deletions packages/editor/src/components/post-text-editor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,26 @@ import Textarea from 'react-autosize-textarea';
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { useState } from '@wordpress/element';
import { parse } from '@wordpress/blocks';
import { withSelect, withDispatch } from '@wordpress/data';
import { withInstanceId, compose } from '@wordpress/compose';
import { useDispatch, useSelect } from '@wordpress/data';
import { useInstanceId } from '@wordpress/compose';
import { VisuallyHidden } from '@wordpress/components';

export class PostTextEditor extends Component {
constructor() {
super( ...arguments );
export default function PostTextEditor() {
const postContent = useSelect(
( select ) => select( 'core/editor' ).getEditedPostContent(),
[]
);

this.edit = this.edit.bind( this );
this.stopEditing = this.stopEditing.bind( this );
const { editPost, resetEditorBlocks } = useDispatch( 'core/editor' );

this.state = {};
}

static getDerivedStateFromProps( props, state ) {
if ( state.isDirty ) {
return null;
}
const [ value, setValue ] = useState( postContent );
const [ isDirty, setIsDirty ] = useState( false );
const instanceId = useInstanceId( PostTextEditor );

return {
value: props.value,
isDirty: false,
};
if ( ! isDirty && value !== postContent ) {
setValue( postContent );
}

/**
Expand All @@ -45,68 +40,47 @@ export class PostTextEditor extends Component {
*
* @param {Event} event Change event.
*/
edit( event ) {
const value = event.target.value;
this.props.onChange( value );
this.setState( { value, isDirty: true } );
}
const onChange = ( event ) => {
const newValue = event.target.value;

editPost( { content: newValue } );

setValue( newValue );
setIsDirty( true );
};

/**
* Function called when the user has completed their edits, responsible for
* ensuring that changes, if made, are surfaced to the onPersist prop
* callback and resetting dirty state.
*/
stopEditing() {
if ( this.state.isDirty ) {
this.props.onPersist( this.state.value );
this.setState( { isDirty: false } );
const stopEditing = () => {
if ( isDirty ) {
const blocks = parse( value );
resetEditorBlocks( blocks );

setIsDirty( false );
}
}
};

render() {
const { value } = this.state;
const { instanceId } = this.props;
return (
<>
<VisuallyHidden
as="label"
htmlFor={ `post-content-${ instanceId }` }
>
{ __( 'Type text or HTML' ) }
</VisuallyHidden>
<Textarea
autoComplete="off"
dir="auto"
value={ value }
onChange={ this.edit }
onBlur={ this.stopEditing }
className="editor-post-text-editor"
id={ `post-content-${ instanceId }` }
placeholder={ __( 'Start writing with text or HTML' ) }
/>
</>
);
}
return (
<>
<VisuallyHidden
as="label"
htmlFor={ `post-content-${ instanceId }` }
>
{ __( 'Type text or HTML' ) }
</VisuallyHidden>
<Textarea
autoComplete="off"
dir="auto"
value={ value }
onChange={ onChange }
onBlur={ stopEditing }
className="editor-post-text-editor"
id={ `post-content-${ instanceId }` }
placeholder={ __( 'Start writing with text or HTML' ) }
/>
</>
);
}

export default compose( [
withSelect( ( select ) => {
const { getEditedPostContent } = select( 'core/editor' );
return {
value: getEditedPostContent(),
};
} ),
withDispatch( ( dispatch ) => {
const { editPost, resetEditorBlocks } = dispatch( 'core/editor' );
return {
onChange( content ) {
editPost( { content } );
},
onPersist( content ) {
const blocks = parse( content );
resetEditorBlocks( blocks );
},
};
} ),
withInstanceId,
] )( PostTextEditor );
174 changes: 109 additions & 65 deletions packages/editor/src/components/post-text-editor/test/index.js
Original file line number Diff line number Diff line change
@@ -1,133 +1,177 @@
/**
* External dependencies
*/
import { create } from 'react-test-renderer';
import { act, create } from 'react-test-renderer';
import Textarea from 'react-autosize-textarea';

/**
* WordPress dependencies
*/
import { useSelect } from '@wordpress/data';

/**
* Internal dependencies
*/
import { PostTextEditor } from '../';
import PostTextEditor from '../';

// "Downgrade" ReactAutosizeTextarea to a regular textarea. Assumes aligned
// props interface.
jest.mock( 'react-autosize-textarea', () => ( props ) => (
<textarea { ...props } />
) );

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;
} );

let mockEditPost = jest.fn();
let mockResetEditorBlocks = jest.fn();

jest.mock( '@wordpress/data/src/components/use-dispatch', () => {
return {
useDispatch: () => ( {
editPost: mockEditPost,
resetEditorBlocks: mockResetEditorBlocks,
} ),
};
} );

describe( 'PostTextEditor', () => {
it( 'should render via the prop value', () => {
const wrapper = create( <PostTextEditor value="Hello World" /> );
beforeEach( () => {
useSelect.mockImplementation( () => 'Hello World' );

mockEditPost = jest.fn();
mockResetEditorBlocks = jest.fn();
} );

it( 'should render via the value from useSelect', () => {
let wrapper;

act( () => {
wrapper = create( <PostTextEditor /> );
} );

const textarea = wrapper.root.findByType( Textarea );
expect( textarea.props.value ).toBe( 'Hello World' );
} );

it( 'should render via the state value when edits made', () => {
const onChange = jest.fn();
const wrapper = create(
<PostTextEditor value="Hello World" onChange={ onChange } />
);
let wrapper;

act( () => {
wrapper = create( <PostTextEditor /> );
} );

const textarea = wrapper.root.findByType( Textarea );
textarea.props.onChange( { target: { value: 'Hello Chicken' } } );

act( () =>
textarea.props.onChange( { target: { value: 'Hello Chicken' } } )
);

expect( textarea.props.value ).toBe( 'Hello Chicken' );
expect( onChange ).toHaveBeenCalledWith( 'Hello Chicken' );
expect( mockEditPost ).toHaveBeenCalledWith( {
content: 'Hello Chicken',
} );
} );

it( 'should render via the state value when edits made, even if prop value changes', () => {
const onChange = jest.fn();
const wrapper = create(
<PostTextEditor value="Hello World" onChange={ onChange } />
);
let wrapper;

act( () => {
wrapper = create( <PostTextEditor /> );
} );

const textarea = wrapper.root.findByType( Textarea );
textarea.props.onChange( { target: { value: 'Hello Chicken' } } );

wrapper.update(
<PostTextEditor value="Goodbye World" onChange={ onChange } />
act( () =>
textarea.props.onChange( { target: { value: 'Hello Chicken' } } )
);

useSelect.mockImplementation( () => 'Goodbye World' );

act( () => {
wrapper.update( <PostTextEditor /> );
} );

expect( textarea.props.value ).toBe( 'Hello Chicken' );
expect( onChange ).toHaveBeenCalledWith( 'Hello Chicken' );
expect( mockEditPost ).toHaveBeenCalledWith( {
content: 'Hello Chicken',
} );
} );

it( 'should render via the state value when edits made, even if prop value changes and state value empty', () => {
const onChange = jest.fn();
const wrapper = create(
<PostTextEditor value="Hello World" onChange={ onChange } />
);
let wrapper;

act( () => {
wrapper = create( <PostTextEditor /> );
} );

const textarea = wrapper.root.findByType( Textarea );
textarea.props.onChange( { target: { value: '' } } );
act( () => textarea.props.onChange( { target: { value: '' } } ) );

wrapper.update(
<PostTextEditor value="Goodbye World" onChange={ onChange } />
);
useSelect.mockImplementation( () => 'Goodbye World' );

act( () => {
wrapper.update( <PostTextEditor /> );
} );

expect( textarea.props.value ).toBe( '' );
expect( onChange ).toHaveBeenCalledWith( '' );
expect( mockEditPost ).toHaveBeenCalledWith( {
content: '',
} );
} );

it( 'calls onPersist after changes made and user stops editing', () => {
const onPersist = jest.fn();
const wrapper = create(
<PostTextEditor
value="Hello World"
onChange={ () => {} }
onPersist={ onPersist }
/>
);
let wrapper;

act( () => {
wrapper = create( <PostTextEditor /> );
} );

const textarea = wrapper.root.findByType( Textarea );
textarea.props.onChange( { target: { value: '' } } );
textarea.props.onBlur();

expect( onPersist ).toHaveBeenCalledWith( '' );
act( () => textarea.props.onChange( { target: { value: '' } } ) );
act( () => textarea.props.onBlur() );

expect( mockResetEditorBlocks ).toHaveBeenCalledWith( [] );
} );

it( 'does not call onPersist after user stops editing without changes', () => {
const onPersist = jest.fn();
const wrapper = create(
<PostTextEditor
value="Hello World"
onChange={ () => {} }
onPersist={ onPersist }
/>
);
let wrapper;

act( () => {
wrapper = create( <PostTextEditor /> );
} );

const textarea = wrapper.root.findByType( Textarea );
textarea.props.onBlur();
act( () => textarea.props.onBlur() );

expect( onPersist ).not.toHaveBeenCalled();
expect( mockResetEditorBlocks ).not.toHaveBeenCalled();
} );

it( 'resets to prop value after user stops editing', () => {
// This isn't the most realistic case, since typically we'd assume the
// parent renderer to pass the value as it had received onPersist. The
// test here is more an edge case to stress that it's intentionally
// differentiating between state and prop values.
const wrapper = create(
<PostTextEditor
value="Hello World"
onChange={ () => {} }
onPersist={ () => {} }
/>
);
let wrapper;

act( () => {
wrapper = create( <PostTextEditor /> );
} );

const textarea = wrapper.root.findByType( Textarea );
textarea.props.onChange( { target: { value: '' } } );

wrapper.update(
<PostTextEditor
value="Goodbye World"
onChange={ () => {} }
onPersist={ () => {} }
/>
);
act( () => textarea.props.onChange( { target: { value: '' } } ) );

useSelect.mockImplementation( () => 'Goodbye World' );

act( () => {
wrapper.update( <PostTextEditor /> );
} );

textarea.props.onBlur();
act( () => textarea.props.onBlur() );

expect( textarea.props.value ).toBe( 'Goodbye World' );
} );
Expand Down

0 comments on commit 39d5086

Please sign in to comment.