diff --git a/editor/block-mover/index.js b/editor/block-mover/index.js index 594a5d38929d8b..7f66166f907504 100644 --- a/editor/block-mover/index.js +++ b/editor/block-mover/index.js @@ -2,7 +2,6 @@ * External dependencies */ import { connect } from 'react-redux'; -import { first, last } from 'lodash'; /** * WordPress dependencies @@ -13,6 +12,7 @@ import IconButton from 'components/icon-button'; * Internal dependencies */ import './style.scss'; +import { isFirstBlock, isLastBlock } from '../selectors'; function BlockMover( { onMoveUp, onMoveDown, isFirst, isLast } ) { return ( @@ -35,8 +35,8 @@ function BlockMover( { onMoveUp, onMoveDown, isFirst, isLast } ) { export default connect( ( state, ownProps ) => ( { - isFirst: first( state.editor.blockOrder ) === ownProps.uid, - isLast: last( state.editor.blockOrder ) === ownProps.uid + isFirst: isFirstBlock( state, ownProps.uid ), + isLast: isLastBlock( state, ownProps.uid ) } ), ( dispatch, ownProps ) => ( { onMoveDown() { diff --git a/editor/block-switcher/index.js b/editor/block-switcher/index.js index c9b5fbf67a7dcf..90723d1cdc1667 100644 --- a/editor/block-switcher/index.js +++ b/editor/block-switcher/index.js @@ -14,6 +14,7 @@ import IconButton from 'components/icon-button'; * Internal dependencies */ import './style.scss'; +import { getBlock } from '../selectors'; class BlockSwitcher extends wp.element.Component { constructor() { @@ -96,7 +97,7 @@ class BlockSwitcher extends wp.element.Component { export default connect( ( state, ownProps ) => ( { - block: state.editor.blocksByUid[ ownProps.uid ] + block: getBlock( state, ownProps.uid ) } ), ( dispatch, ownProps ) => ( { onTransform( block, blockType ) { diff --git a/editor/header/mode-switcher/index.js b/editor/header/mode-switcher/index.js index ef25f9c7b46e08..f91e85c1c43785 100644 --- a/editor/header/mode-switcher/index.js +++ b/editor/header/mode-switcher/index.js @@ -12,6 +12,7 @@ import Dashicon from 'components/dashicon'; * Internal dependencies */ import './style.scss'; +import { getEditorMode } from '../../selectors'; /** * Set of available mode options. @@ -57,7 +58,7 @@ function ModeSwitcher( { mode, onSwitch } ) { export default connect( ( state ) => ( { - mode: state.mode + mode: getEditorMode( state ) } ), ( dispatch ) => ( { onSwitch( mode ) { diff --git a/editor/header/saved-state/index.js b/editor/header/saved-state/index.js index 8dc4d2381b2c53..0b5e16fe502377 100644 --- a/editor/header/saved-state/index.js +++ b/editor/header/saved-state/index.js @@ -13,6 +13,7 @@ import Dashicon from 'components/dashicon'; * Internal dependencies */ import './style.scss'; +import { isEditedPostDirty } from '../../selectors'; function SavedState( { isDirty } ) { const classes = classNames( 'editor-saved-state', { @@ -35,6 +36,6 @@ function SavedState( { isDirty } ) { export default connect( ( state ) => ( { - isDirty: state.editor.dirty, + isDirty: isEditedPostDirty( state ), } ) )( SavedState ); diff --git a/editor/header/tools/index.js b/editor/header/tools/index.js index 405019fc93536a..0906cb40e18734 100644 --- a/editor/header/tools/index.js +++ b/editor/header/tools/index.js @@ -16,6 +16,7 @@ import Button from 'components/button'; import './style.scss'; import Inserter from '../../inserter'; import PublishButton from './publish-button'; +import { isEditorSidebarOpened, hasEditorUndo, hasEditorRedo } from '../../selectors'; function Tools( { undo, redo, hasUndo, hasRedo, isSidebarOpened, toggleSidebar } ) { return ( @@ -50,9 +51,9 @@ function Tools( { undo, redo, hasUndo, hasRedo, isSidebarOpened, toggleSidebar } export default connect( ( state ) => ( { - hasUndo: state.editor.history.past.length > 0, - hasRedo: state.editor.history.future.length > 0, - isSidebarOpened: state.isSidebarOpened, + hasUndo: hasEditorUndo( state ), + hasRedo: hasEditorRedo( state ), + isSidebarOpened: isEditorSidebarOpened( state ), } ), ( dispatch ) => ( { undo: () => dispatch( { type: 'UNDO' } ), diff --git a/editor/header/tools/publish-button.js b/editor/header/tools/publish-button.js index 9986b2dbc9c265..2d3c9ae266a781 100644 --- a/editor/header/tools/publish-button.js +++ b/editor/header/tools/publish-button.js @@ -12,6 +12,16 @@ import Button from 'components/button'; * Internal dependencies */ import { savePost } from '../../actions'; +import { + isEditedPostDirty, + getCurrentPost, + getPostEdits, + getBlocks, + isSavingPost, + didPostSaveRequestSucceed, + didPostSaveRequestFail, + isSavingNewPost +} from '../../selectors'; function PublishButton( { post, @@ -66,16 +76,14 @@ function PublishButton( { export default connect( ( state ) => ( { - post: state.currentPost, - edits: state.editor.edits, - dirty: state.editor.dirty, - blocks: state.editor.blockOrder.map( ( uid ) => ( - state.editor.blocksByUid[ uid ] - ) ), - isRequesting: state.saving.requesting, - isSuccessful: state.saving.successful, - isError: !! state.saving.error, - requestIsNewPost: state.saving.isNew, + post: getCurrentPost( state ), + edits: getPostEdits( state ), + dirty: isEditedPostDirty( state ), + blocks: getBlocks( state ), + isRequesting: isSavingPost( state ), + isSuccessful: didPostSaveRequestSucceed( state ), + isError: !! didPostSaveRequestFail( state ), + requestIsNewPost: isSavingNewPost( state ), } ), ( dispatch ) => ( { onUpdate( post, edits, blocks ) { diff --git a/editor/layout/index.js b/editor/layout/index.js index 17e90ae822ae3d..e58edd5128a17b 100644 --- a/editor/layout/index.js +++ b/editor/layout/index.js @@ -12,6 +12,7 @@ import Header from '../header'; import Sidebar from '../sidebar'; import TextEditor from '../modes/text-editor'; import VisualEditor from '../modes/visual-editor'; +import { getEditorMode, isEditorSidebarOpened } from '../selectors'; function Layout( { mode, isSidebarOpened } ) { const className = classnames( 'editor-layout', { @@ -31,6 +32,6 @@ function Layout( { mode, isSidebarOpened } ) { } export default connect( ( state ) => ( { - mode: state.mode, - isSidebarOpened: state.isSidebarOpened + mode: getEditorMode( state ), + isSidebarOpened: isEditorSidebarOpened( state ) } ) )( Layout ); diff --git a/editor/modes/text-editor/index.js b/editor/modes/text-editor/index.js index 9aeb94ed26d213..682294f83ad286 100644 --- a/editor/modes/text-editor/index.js +++ b/editor/modes/text-editor/index.js @@ -9,6 +9,7 @@ import Textarea from 'react-autosize-textarea'; */ import './style.scss'; import PostTitle from '../../post-title'; +import { getBlocks } from '../../selectors'; function TextEditor( { blocks, onChange } ) { return ( @@ -45,9 +46,7 @@ function TextEditor( { blocks, onChange } ) { export default connect( ( state ) => ( { - blocks: state.editor.blockOrder.map( ( uid ) => ( - state.editor.blocksByUid[ uid ] - ) ) + blocks: getBlocks( state ) } ), ( dispatch ) => ( { onChange( value ) { diff --git a/editor/modes/visual-editor/block.js b/editor/modes/visual-editor/block.js index c2ee81bae0ab80..972b224d97b13f 100644 --- a/editor/modes/visual-editor/block.js +++ b/editor/modes/visual-editor/block.js @@ -16,6 +16,15 @@ import Toolbar from 'components/toolbar'; */ import BlockMover from '../../block-mover'; import BlockSwitcher from '../../block-switcher'; +import { + getPreviousBlock, + getBlock, + getBlockFocus, + getBlockOrder, + isBlockHovered, + isBlockSelected, + isTypingInBlock +} from '../../selectors'; class VisualEditorBlock extends wp.element.Component { constructor() { @@ -230,15 +239,14 @@ class VisualEditorBlock extends wp.element.Component { export default connect( ( state, ownProps ) => { - const order = state.editor.blockOrder.indexOf( ownProps.uid ); return { - previousBlock: state.editor.blocksByUid[ state.editor.blockOrder[ order - 1 ] ] || null, - block: state.editor.blocksByUid[ ownProps.uid ], - isSelected: state.selectedBlock.uid === ownProps.uid, - isHovered: state.hoveredBlock === ownProps.uid, - focus: state.selectedBlock.uid === ownProps.uid ? state.selectedBlock.focus : null, - isTyping: state.selectedBlock.uid === ownProps.uid ? state.selectedBlock.typing : false, - order + previousBlock: getPreviousBlock( state, ownProps.uid ), + block: getBlock( state, ownProps.uid ), + isSelected: isBlockSelected( state, ownProps.uid ), + isHovered: isBlockHovered( state, ownProps.uid ), + focus: getBlockFocus( state, ownProps.uid ), + isTyping: isTypingInBlock( state, ownProps.uid ), + order: getBlockOrder( state, ownProps.uid ) }; }, ( dispatch, ownProps ) => ( { diff --git a/editor/modes/visual-editor/index.js b/editor/modes/visual-editor/index.js index b1447b32c5e14d..8859ba4bc123a9 100644 --- a/editor/modes/visual-editor/index.js +++ b/editor/modes/visual-editor/index.js @@ -10,6 +10,7 @@ import './style.scss'; import Inserter from '../../inserter'; import VisualEditorBlock from './block'; import PostTitle from '../../post-title'; +import { getBlockUids } from '../../selectors'; function VisualEditor( { blocks } ) { return ( @@ -24,5 +25,5 @@ function VisualEditor( { blocks } ) { } export default connect( ( state ) => ( { - blocks: state.editor.blockOrder + blocks: getBlockUids( state ) } ) )( VisualEditor ); diff --git a/editor/post-title/index.js b/editor/post-title/index.js index e1f7326d452d93..3b85d502cae080 100644 --- a/editor/post-title/index.js +++ b/editor/post-title/index.js @@ -8,6 +8,7 @@ import Textarea from 'react-autosize-textarea'; * Internal dependencies */ import './style.scss'; +import { getEditedPostTitle } from '../selectors'; /** * Constants @@ -34,9 +35,7 @@ function PostTitle( { title, onUpdate } ) { export default connect( ( state ) => ( { - title: state.editor.edits.title === undefined - ? state.currentPost.title.raw - : state.editor.edits.title, + title: getEditedPostTitle( state ), } ), ( dispatch ) => { return { diff --git a/editor/selectors.js b/editor/selectors.js new file mode 100644 index 00000000000000..7932d5b4f08b6a --- /dev/null +++ b/editor/selectors.js @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import { first, last } from 'lodash'; + +export function getEditorMode( state ) { + return state.mode; +} + +export function isEditorSidebarOpened( state ) { + return state.isSidebarOpened; +} + +export function hasEditorUndo( state ) { + return state.editor.history.past.length > 0; +} + +export function hasEditorRedo( state ) { + return state.editor.history.future.length > 0; +} + +export function isEditedPostDirty( state ) { + return state.editor.dirty; +} + +export function getCurrentPost( state ) { + return state.currentPost; +} + +export function getPostEdits( state ) { + return state.editor.edits; +} + +export function getEditedPostTitle( state ) { + return state.editor.edits.title === undefined + ? state.currentPost.title.raw + : state.editor.edits.title; +} + +export function getBlock( state, uid ) { + return state.editor.blocksByUid[ uid ]; +} + +export function getBlocks( state ) { + return state.editor.blockOrder.map( ( uid ) => ( + state.editor.blocksByUid[ uid ] + ) ); +} + +export function getBlockUids( state ) { + return state.editor.blockOrder; +} + +export function getBlockOrder( state, uid ) { + return state.editor.blockOrder.indexOf( uid ); +} + +export function isFirstBlock( state, uid ) { + return first( state.editor.blockOrder ) === uid; +} + +export function isLastBlock( state, uid ) { + return last( state.editor.blockOrder ) === uid; +} + +export function getPreviousBlock( state, uid ) { + const order = getBlockOrder( state, uid ); + return state.editor.blocksByUid[ state.editor.blockOrder[ order - 1 ] ] || null; +} + +export function isBlockSelected( state, uid ) { + return state.selectedBlock.uid === uid; +} + +export function isBlockHovered( state, uid ) { + return state.hoveredBlock === uid; +} + +export function getBlockFocus( state, uid ) { + return state.selectedBlock.uid === uid ? state.selectedBlock.focus : null; +} + +export function isTypingInBlock( state, uid ) { + return state.selectedBlock.uid === uid ? state.selectedBlock.typing : false; +} + +export function isSavingPost( state ) { + return state.saving.requesting; +} + +export function didPostSaveRequestSucceed( state ) { + return state.saving.successful; +} + +export function didPostSaveRequestFail( state ) { + return !! state.saving.error; +} + +export function isSavingNewPost( state ) { + return state.saving.isNew; +} diff --git a/editor/test/selectors.js b/editor/test/selectors.js new file mode 100644 index 00000000000000..84b50ec27c413b --- /dev/null +++ b/editor/test/selectors.js @@ -0,0 +1,496 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import { + getEditorMode, + isEditorSidebarOpened, + hasEditorUndo, + hasEditorRedo, + isEditedPostDirty, + getCurrentPost, + getPostEdits, + getEditedPostTitle, + getBlock, + getBlocks, + getBlockUids, + getBlockOrder, + isFirstBlock, + isLastBlock, + getPreviousBlock, + isBlockSelected, + isBlockHovered, + getBlockFocus, + isTypingInBlock, + isSavingPost, + didPostSaveRequestSucceed, + didPostSaveRequestFail, + isSavingNewPost +} from '../selectors'; + +describe( 'selectors', () => { + describe( 'getEditorMode', () => { + it( 'should return the selected editor mode', () => { + const state = { + mode: 'visual' + }; + + expect( getEditorMode( state ) ).to.eql( 'visual' ); + } ); + } ); + + describe( 'isEditorSidebarOpened', () => { + it( 'should return true when the sidebar is opened', () => { + const state = { + isSidebarOpened: true + }; + + expect( isEditorSidebarOpened( state ) ).to.be.true(); + } ); + + it( 'should return false when the sidebar is opened', () => { + const state = { + isSidebarOpened: false + }; + + expect( isEditorSidebarOpened( state ) ).to.be.false(); + } ); + } ); + + describe( 'hasEditorUndo', () => { + it( 'should return true when the past history is not empty', () => { + const state = { + editor: { + history: { + past: [ + {} + ] + } + } + }; + + expect( hasEditorUndo( state ) ).to.be.true(); + } ); + + it( 'should return false when the past history is empty', () => { + const state = { + editor: { + history: { + past: [] + } + } + }; + + expect( hasEditorUndo( state ) ).to.be.false(); + } ); + } ); + + describe( 'hasEditorRedo', () => { + it( 'should return true when the future history is not empty', () => { + const state = { + editor: { + history: { + future: [ + {} + ] + } + } + }; + + expect( hasEditorRedo( state ) ).to.be.true(); + } ); + + it( 'should return false when the future history is empty', () => { + const state = { + editor: { + history: { + future: [] + } + } + }; + + expect( hasEditorRedo( state ) ).to.be.false(); + } ); + } ); + + describe( 'isEditedPostDirty', () => { + it( 'should return true when the post is dirty', () => { + const state = { + editor: { + dirty: true + } + }; + + expect( isEditedPostDirty( state ) ).to.be.true(); + } ); + + it( 'should return false when the post is not dirty', () => { + const state = { + editor: { + dirty: false + } + }; + + expect( isEditedPostDirty( state ) ).to.be.false(); + } ); + } ); + + describe( 'getCurrentPost', () => { + it( 'should return the current post', () => { + const state = { + currentPost: { ID: 1 } + }; + + expect( getCurrentPost( state ) ).to.eql( { ID: 1 } ); + } ); + } ); + + describe( 'getPostEdits', () => { + it( 'should return the post edits', () => { + const state = { + editor: { + edits: { title: 'terga' } + } + }; + + expect( getPostEdits( state ) ).to.eql( { title: 'terga' } ); + } ); + } ); + + describe( 'getEditedPostTitle', () => { + it( 'should return the post saved title if the title is not edited', () => { + const state = { + currentPost: { + title: { raw: 'sassel' } + }, + editor: { + edits: { status: 'private' } + } + }; + + expect( getEditedPostTitle( state ) ).to.equal( 'sassel' ); + } ); + + it( 'should return the edited title', () => { + const state = { + currentPost: { + title: { raw: 'sassel' } + }, + editor: { + edits: { title: 'youcha' } + } + }; + + expect( getEditedPostTitle( state ) ).to.equal( 'youcha' ); + } ); + } ); + + describe( 'getBlock', () => { + it( 'should return the block', () => { + const state = { + editor: { + blocksByUid: { + 123: { uid: 123, blockType: 'core/text' } + } + } + }; + + expect( getBlock( state, 123 ) ).to.eql( { uid: 123, blockType: 'core/text' } ); + } ); + } ); + + describe( 'getBlocks', () => { + it( 'should return the ordered blocks', () => { + const state = { + editor: { + blocksByUid: { + 23: { uid: 23, blockType: 'core/heading' }, + 123: { uid: 123, blockType: 'core/text' } + }, + blockOrder: [ 123, 23 ] + } + }; + + expect( getBlocks( state ) ).to.eql( [ + { uid: 123, blockType: 'core/text' }, + { uid: 23, blockType: 'core/heading' } + ] ); + } ); + } ); + + describe( 'getBlockUids', () => { + it( 'should return the ordered block UIDs', () => { + const state = { + editor: { + blockOrder: [ 123, 23 ] + } + }; + + expect( getBlockUids( state ) ).to.eql( [ 123, 23 ] ); + } ); + } ); + + describe( 'getBlockOrder', () => { + it( 'should return the block order', () => { + const state = { + editor: { + blockOrder: [ 123, 23 ] + } + }; + + expect( getBlockOrder( state, 23 ) ).to.equal( 1 ); + } ); + } ); + + describe( 'isFirstBlock', () => { + it( 'should return true when the block is first', () => { + const state = { + editor: { + blockOrder: [ 123, 23 ] + } + }; + + expect( isFirstBlock( state, 123 ) ).to.be.true(); + } ); + + it( 'should return false when the block is not first', () => { + const state = { + editor: { + blockOrder: [ 123, 23 ] + } + }; + + expect( isFirstBlock( state, 23 ) ).to.be.false(); + } ); + } ); + + describe( 'isLastBlock', () => { + it( 'should return true when the block is last', () => { + const state = { + editor: { + blockOrder: [ 123, 23 ] + } + }; + + expect( isLastBlock( state, 23 ) ).to.be.true(); + } ); + + it( 'should return false when the block is not last', () => { + const state = { + editor: { + blockOrder: [ 123, 23 ] + } + }; + + expect( isLastBlock( state, 123 ) ).to.be.false(); + } ); + } ); + + describe( 'getPreviousBlock', () => { + it( 'should return the previous block', () => { + const state = { + editor: { + blocksByUid: { + 23: { uid: 23, blockType: 'core/heading' }, + 123: { uid: 123, blockType: 'core/text' } + }, + blockOrder: [ 123, 23 ] + } + }; + + expect( getPreviousBlock( state, 23 ) ).to.eql( + { uid: 123, blockType: 'core/text' }, + ); + } ); + + it( 'should return null for the first block', () => { + const state = { + editor: { + blocksByUid: { + 23: { uid: 23, blockType: 'core/heading' }, + 123: { uid: 123, blockType: 'core/text' } + }, + blockOrder: [ 123, 23 ] + } + }; + + expect( getPreviousBlock( state, 123 ) ).to.be.null(); + } ); + } ); + + describe( 'isBlockSelected', () => { + it( 'should return true if the block is selected', () => { + const state = { + selectedBlock: { uid: 123 } + }; + + expect( isBlockSelected( state, 123 ) ).to.be.true(); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + selectedBlock: { uid: 123 } + }; + + expect( isBlockSelected( state, 23 ) ).to.be.false(); + } ); + } ); + + describe( 'isBlockHovered', () => { + it( 'should return true if the block is hovered', () => { + const state = { + hoveredBlock: 123 + }; + + expect( isBlockHovered( state, 123 ) ).to.be.true(); + } ); + + it( 'should return false if the block is not hovered', () => { + const state = { + hoveredBlock: 123 + }; + + expect( isBlockHovered( state, 23 ) ).to.be.false(); + } ); + } ); + + describe( 'getBlockFocus', () => { + it( 'should return the block focus if the block is selected', () => { + const state = { + selectedBlock: { + uid: 123, + focus: { editable: 'cite' } + } + }; + + expect( getBlockFocus( state, 123 ) ).to.be.eql( { editable: 'cite' } ); + } ); + + it( 'should return null if the block is not selected', () => { + const state = { + selectedBlock: { + uid: 123, + focus: { editable: 'cite' } + } + }; + + expect( getBlockFocus( state, 23 ) ).to.be.eql( null ); + } ); + } ); + + describe( 'isTypingInBlock', () => { + it( 'should return the isTyping flag if the block is selected', () => { + const state = { + selectedBlock: { + uid: 123, + typing: true + } + }; + + expect( isTypingInBlock( state, 123 ) ).to.be.true(); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + selectedBlock: { + uid: 123, + typing: true + } + }; + + expect( isTypingInBlock( state, 23 ) ).to.be.false(); + } ); + } ); + + describe( 'isSavingPost', () => { + it( 'should return true if the post is currently being saved', () => { + const state = { + saving: { + requesting: true + } + }; + + expect( isSavingPost( state ) ).to.be.true(); + } ); + + it( 'should return false if the post is currently being saved', () => { + const state = { + saving: { + requesting: false + } + }; + + expect( isSavingPost( state ) ).to.be.false(); + } ); + } ); + + describe( 'didPostSaveRequestSucceed', () => { + it( 'should return true if the post save request is successful', () => { + const state = { + saving: { + successful: true + } + }; + + expect( didPostSaveRequestSucceed( state ) ).to.be.true(); + } ); + + it( 'should return true if the post save request has failed', () => { + const state = { + saving: { + successful: false + } + }; + + expect( didPostSaveRequestSucceed( state ) ).to.be.false(); + } ); + } ); + + describe( 'didPostSaveRequestFail', () => { + it( 'should return true if the post save request has failed', () => { + const state = { + saving: { + error: 'error' + } + }; + + expect( didPostSaveRequestFail( state ) ).to.be.true(); + } ); + + it( 'should return true if the post save request is successful', () => { + const state = { + saving: { + error: false + } + }; + + expect( didPostSaveRequestFail( state ) ).to.be.false(); + } ); + } ); + + describe( 'isSavingNewPost', () => { + it( 'should return true if the post being saved is new', () => { + const state = { + saving: { + isNew: true + } + }; + + expect( isSavingNewPost( state ) ).to.be.true(); + } ); + + it( 'should return false if the post being saved is not new', () => { + const state = { + saving: { + isNew: false + } + }; + + expect( isSavingNewPost( state ) ).to.be.false(); + } ); + } ); +} );