diff --git a/editor/modes/visual-editor/block.js b/editor/modes/visual-editor/block.js index 228855950c784..e6488659a6a9b 100644 --- a/editor/modes/visual-editor/block.js +++ b/editor/modes/visual-editor/block.js @@ -29,7 +29,7 @@ import { getNextBlock, getBlock, getBlockFocus, - getBlockOrder, + getBlockIndex, isBlockHovered, isBlockSelected, isBlockMultiSelected, @@ -302,7 +302,7 @@ export default connect( isHovered: isBlockHovered( state, ownProps.uid ), focus: getBlockFocus( state, ownProps.uid ), isTyping: isTypingInBlock( state, ownProps.uid ), - order: getBlockOrder( state, ownProps.uid ), + order: getBlockIndex( state, ownProps.uid ), }; }, ( dispatch, ownProps ) => ( { diff --git a/editor/selectors.js b/editor/selectors.js index 74bc8a260ee56..c24555ab172fd 100644 --- a/editor/selectors.js +++ b/editor/selectors.js @@ -8,50 +8,117 @@ import { first, last, get } from 'lodash'; */ import { addQueryArgs } from './utils/url'; +/** + * Returns the current editing mode. + * + * @param {Object} state Global application state + * @return {String} Editing mode + */ export function getEditorMode( state ) { return state.mode; } +/** + * Returns true if the editor sidebar panel is open, or false otherwise. + * + * @param {Object} state Global application state + * @return {Boolean} Whether sidebar is open + */ export function isEditorSidebarOpened( state ) { return state.isSidebarOpened; } +/** + * Returns true if any past editor history snapshots exist, or false otherwise. + * + * @param {Object} state Global application state + * @return {Boolean} Whether undo history exists + */ export function hasEditorUndo( state ) { return state.editor.history.past.length > 0; } +/** + * Returns true if any future editor history snapshots exist, or false + * otherwise. + * + * @param {Object} state Global application state + * @return {Boolean} Whether redo history exists + */ export function hasEditorRedo( state ) { return state.editor.history.future.length > 0; } +/** + * Returns true if the currently edited post is yet to be saved, or false if + * the post has been saved. + * + * @param {Object} state Global application state + * @return {Boolean} Whether the post is new + */ export function isEditedPostNew( state ) { return ! state.currentPost.id; } +/** + * Returns true if there are unsaved values for the current edit session, or + * false if the editing state matches the saved or new post. + * + * @param {Object} state Global application state + * @return {Boolean} Whether unsaved values exist + */ export function isEditedPostDirty( state ) { return state.editor.dirty; } +/** + * Returns the post currently being edited in its last known saved state, not + * including unsaved edits. Returns an object containing relevant default post + * values if the post has not yet been saved. + * + * @param {Object} state Global application state + * @return {Object} Post object + */ export function getCurrentPost( state ) { return state.currentPost; } +/** + * Returns any post values which have been changed in the editor but not yet + * been saved. + * + * @param {Object} state Global application state + * @return {Object} Object of key value pairs comprising unsaved edits + */ export function getPostEdits( state ) { return state.editor.edits; } +/** + * Returns a single attribute of the post being edited, preferring the unsaved + * edit if one exists, but falling back to the attribute for the last known + * saved state of the post. + * + * @param {Object} state Global application state + * @param {String} attributeName Post attribute name + * @return {*} Post attribute value + */ export function getEditedPostAttribute( state, attributeName ) { return state.editor.edits[ attributeName ] === undefined ? state.currentPost[ attributeName ] : state.editor.edits[ attributeName ]; } -export function getEditedPostStatus( state ) { - return getEditedPostAttribute( state, 'status' ); -} - +/** + * Returns the current visibility of the post being edited, preferring the + * unsaved value if different than the saved post. The return value is one of + * "private", "password", or "public". + * + * @param {Object} state Global application state + * @return {String} Post visiblity + */ export function getEditedPostVisibility( state ) { - const status = getEditedPostStatus( state ); + const status = getEditedPostAttribute( state, 'status' ); const password = getEditedPostAttribute( state, 'password' ); if ( status === 'private' ) { @@ -62,18 +129,38 @@ export function getEditedPostVisibility( state ) { return 'public'; } +/** + * Returns the raw title of the post being edited, preferring the unsaved value + * if different than the saved post. + * + * @param {Object} state Global application state + * @return {String} Raw post title + */ export function getEditedPostTitle( state ) { return state.editor.edits.title === undefined ? get( state.currentPost, 'title.raw' ) : state.editor.edits.title; } +/** + * Returns the raw excerpt of the post being edited, preferring the unsaved + * value if different than the saved post. + * + * @param {Object} state Global application state + * @return {String} Raw post excerpt + */ export function getEditedPostExcerpt( state ) { return state.editor.edits.excerpt === undefined ? get( state.currentPost, 'excerpt.raw' ) : state.editor.edits.excerpt; } +/** + * Returns a URL to preview the post being edited. + * + * @param {Object} state Global application state + * @return {String} Preview URL + */ export function getEditedPostPreviewLink( state ) { const link = state.currentPost.link; if ( ! link ) { @@ -83,16 +170,39 @@ export function getEditedPostPreviewLink( state ) { return addQueryArgs( link, { preview: 'true' } ); } +/** + * Returns a block given its unique ID. This is a parsed copy of the block, + * containing its `blockName`, identifier (`uid`), and current `attributes` + * state. This is not the block's registration settings, which must be + * retrieved from the blocks module registration store. + * + * @param {Object} state Global application state + * @param {String} uid Block unique ID + * @return {Object} Parsed block object + */ export function getBlock( state, uid ) { return state.editor.blocksByUid[ uid ]; } +/** + * Returns all block objects for the current post being edited as an array in + * the order they appear in the post. + * + * @param {Object} state Global application state + * @return {Object[]} Post blocks + */ export function getBlocks( state ) { return state.editor.blockOrder.map( ( uid ) => ( state.editor.blocksByUid[ uid ] ) ); } +/** + * Returns the currently selected block, or null if there is no selected block. + * + * @param {Object} state Global application state + * @return {?Object} Selected block + */ export function getSelectedBlock( state ) { const { uid } = state.selectedBlock; const { start, end } = state.multiSelectedBlocks; @@ -104,6 +214,13 @@ export function getSelectedBlock( state ) { return state.editor.blocksByUid[ uid ]; } +/** + * Returns the current multi-selection set of blocks, or an empty array if + * there is no multi-selection. + * + * @param {Object} state Global application state + * @return {Array} Multi-selected block objects + */ export function getSelectedBlocks( state ) { const { blockOrder } = state.editor; const { start, end } = state.multiSelectedBlocks; @@ -122,44 +239,124 @@ export function getSelectedBlocks( state ) { return blockOrder.slice( startIndex, endIndex + 1 ); } +/** + * Returns the unique ID of the block which begins the multi-selection set, or + * null if there is no multi-selectino. + * + * @param {Object} state Global application state + * @return {?String} Unique ID of block beginning multi-selection + */ export function getBlockSelectionStart( state ) { - return state.multiSelectedBlocks.start; + return state.multiSelectedBlocks.start || null; } +/** + * Returns the unique ID of the block which ends the multi-selection set, or + * null if there is no multi-selectino. + * + * @param {Object} state Global application state + * @return {?String} Unique ID of block ending multi-selection + */ export function getBlockSelectionEnd( state ) { - return state.multiSelectedBlocks.end; + return state.multiSelectedBlocks.end || null; } +/** + * Returns an array containing all block unique IDs of the post being edited, + * in the order they appear in the post. + * + * @param {Object} state Global application state + * @return {Array} Ordered unique IDs of post blocks + */ export function getBlockUids( state ) { return state.editor.blockOrder; } -export function getBlockOrder( state, uid ) { +/** + * Returns the index at which the block corresponding to the specified unique + * ID occurs within the post block order, or `-1` if the block does not exist. + * + * @param {Object} state Global application state + * @param {String} uid Block unique ID + * @return {Number} Index at which block exists in order + */ +export function getBlockIndex( state, uid ) { return state.editor.blockOrder.indexOf( uid ); } +/** + * Returns true if the block corresponding to the specified unique ID is the + * first block of the post, or false otherwise. + * + * @param {Object} state Global application state + * @param {Object} uid Block unique ID + * @return {Boolean} Whether block is first in post + */ export function isFirstBlock( state, uid ) { return first( state.editor.blockOrder ) === uid; } +/** + * Returns true if a multi-selection exists, and the block corresponding to the + * specified unique ID is the first block of the multi-selection set, or false + * otherwise. + * + * @param {Object} state Global application state + * @param {Object} uid Block unique ID + * @return {Boolean} Whether block is first in mult-selection + */ export function isFirstSelectedBlock( state, uid ) { return first( getSelectedBlocks( state ) ) === uid; } +/** + * Returns true if the block corresponding to the specified unique ID is the + * last block of the post, or false otherwise. + * + * @param {Object} state Global application state + * @param {Object} uid Block unique ID + * @return {Boolean} Whether block is last in post + */ export function isLastBlock( state, uid ) { return last( state.editor.blockOrder ) === uid; } +/** + * Returns the block object occurring before the one corresponding to the + * specified unique ID. + * + * @param {Object} state Global application state + * @param {Object} uid Block unique ID + * @return {Object} Block occurring before specified unique ID + */ export function getPreviousBlock( state, uid ) { - const order = getBlockOrder( state, uid ); + const order = getBlockIndex( state, uid ); return state.editor.blocksByUid[ state.editor.blockOrder[ order - 1 ] ] || null; } +/** + * Returns the block object occurring after the one corresponding to the + * specified unique ID. + * + * @param {Object} state Global application state + * @param {Object} uid Block unique ID + * @return {Object} Block occurring after specified unique ID + */ export function getNextBlock( state, uid ) { - const order = getBlockOrder( state, uid ); + const order = getBlockIndex( state, uid ); return state.editor.blocksByUid[ state.editor.blockOrder[ order + 1 ] ] || null; } +/** + * Returns true if the block corresponding to the specified unique ID is + * currently selected and a multi-selection exists, null if there is no + * multi-selection active, or false if multi-selection exists, but the + * specified unique ID is not the selected block. + * + * @param {Object} state Global application state + * @param {Object} uid Block unique ID + * @return {Boolean} Whether block is selected and multi-selection exists + */ export function isBlockSelected( state, uid ) { const { start, end } = state.multiSelectedBlocks; @@ -170,14 +367,39 @@ export function isBlockSelected( state, uid ) { return state.selectedBlock.uid === uid; } +/** + * Returns true if the unique ID occurs within the block multi-selection, or + * false otherwise. + * + * @param {Object} state Global application state + * @param {Object} uid Block unique ID + * @return {Boolean} Whether block is in multi-selection set + */ export function isBlockMultiSelected( state, uid ) { return getSelectedBlocks( state ).indexOf( uid ) !== -1; } +/** + * Returns true if the cursor is hovering the block corresponding to the + * specified unique ID, or false otherwise. + * + * @param {Object} state Global application state + * @param {Object} uid Block unique ID + * @return {Boolean} Whether block is hovered + */ export function isBlockHovered( state, uid ) { return state.hoveredBlock === uid; } +/** + * Returns focus state of the block corresponding to the specified unique ID, + * or null if the block is not selected. It is left to a block's implementation + * to manage the content of this object, defaulting to an empty object. + * + * @param {Object} state Global application state + * @param {Object} uid Block unique ID + * @return {Object} Block focus state + */ export function getBlockFocus( state, uid ) { if ( ! isBlockSelected( state, uid ) ) { return null; @@ -186,6 +408,14 @@ export function getBlockFocus( state, uid ) { return state.selectedBlock.focus; } +/** + * Returns true if the user is typing within the block corresponding to the + * specified unique ID, or false otherwise. + * + * @param {Object} state Global application state + * @param {Object} uid Block unique ID + * @return {Boolean} Whether user is typing within block + */ export function isTypingInBlock( state, uid ) { if ( ! isBlockSelected( state, uid ) ) { return false; @@ -194,6 +424,14 @@ export function isTypingInBlock( state, uid ) { return state.selectedBlock.typing; } +/** + * Returns the unique ID of the block after which a new block insertion would + * be placed, or null if the insertion point is not shown. Defaults to the + * unique ID of the last block occurring in the post if not otherwise assigned. + * + * @param {Object} state Global application state + * @return {?String} Unique ID after which insertion will occur + */ export function getBlockInsertionPoint( state ) { if ( ! state.insertionPoint.show ) { return null; @@ -203,28 +441,62 @@ export function getBlockInsertionPoint( state ) { return blockToInsertAfter || last( state.editor.blockOrder ); } +/** + * Returns true if the post is currently being saved, or false otherwise. + * + * @param {Object} state Global application state + * @return {Boolean} Whether post is being saved + */ export function isSavingPost( state ) { return state.saving.requesting; } +/** + * Returns true if a previous post save was attempted successfully, or false + * otherwise. + * + * @param {Object} state Global application state + * @return {Boolean} Whether the post was saved successfully + */ export function didPostSaveRequestSucceed( state ) { return state.saving.successful; } +/** + * Returns true if a previous post save was attempted but failed, or false + * otherwise. + * + * @param {Object} state Global application state + * @return {Boolean} Whether the post save failed + */ export function didPostSaveRequestFail( state ) { return !! state.saving.error; } +/** + * Returns true if the post being saved is a new draft, or false otherwise. + * + * @param {Object} state Global application state + * @return {Boolean} Whether post being saved is a new draft + */ export function isSavingNewPost( state ) { return state.saving.isNew; } +/** + * Returns a suggested post format for the current post, inferred only if there + * is a single block within the post and it is of a type known to match a + * default post format. Returns null if the format cannot be determined. + * + * @param {Object} state Global application state + * @return {?String} Suggested post format + */ export function getSuggestedPostFormat( state ) { const blocks = state.editor.blockOrder; let type; - // If there is only one block in the content of the post grab its - // `blockType` name so we can derive a suitable post format from it. + // If there is only one block in the content of the post grab its name so + // so we can derive a suitable post format from it. if ( blocks.length === 1 ) { type = state.editor.blocksByUid[ blocks[ 0 ] ].blockType; } @@ -235,7 +507,7 @@ export function getSuggestedPostFormat( state ) { return 'Image'; case 'core/quote': return 'Quote'; - default: - return false; } + + return null; } diff --git a/editor/sidebar/post-status/index.js b/editor/sidebar/post-status/index.js index 4938699883de5..38adb5288280d 100644 --- a/editor/sidebar/post-status/index.js +++ b/editor/sidebar/post-status/index.js @@ -17,7 +17,7 @@ import './style.scss'; import PostVisibility from '../post-visibility'; import PostTrash from '../post-trash'; import PostSchedule from '../post-schedule'; -import { getEditedPostStatus, getSuggestedPostFormat } from '../../selectors'; +import { getEditedPostAttribute, getSuggestedPostFormat } from '../../selectors'; import { editPost } from '../../actions'; class PostStatus extends Component { @@ -71,7 +71,7 @@ PostStatus.instances = 1; export default connect( ( state ) => ( { - status: getEditedPostStatus( state ), + status: getEditedPostAttribute( state, 'status' ), suggestedFormat: getSuggestedPostFormat( state ), } ), ( dispatch ) => { diff --git a/editor/sidebar/post-visibility/index.js b/editor/sidebar/post-visibility/index.js index a1a5282ee8233..7740d115f8ae6 100644 --- a/editor/sidebar/post-visibility/index.js +++ b/editor/sidebar/post-visibility/index.js @@ -17,7 +17,6 @@ import { Component } from 'element'; import './style.scss'; import { getEditedPostAttribute, - getEditedPostStatus, getEditedPostVisibility, } from '../../selectors'; import { editPost } from '../../actions'; @@ -124,7 +123,7 @@ class PostVisibility extends Component { export default connect( ( state ) => ( { - status: getEditedPostStatus( state ), + status: getEditedPostAttribute( state, 'status' ), visibility: getEditedPostVisibility( state ), password: getEditedPostAttribute( state, 'password' ), } ), diff --git a/editor/test/selectors.js b/editor/test/selectors.js index a7f1bae80e4cb..0aa33e0cb7d85 100644 --- a/editor/test/selectors.js +++ b/editor/test/selectors.js @@ -15,7 +15,6 @@ import { isEditedPostDirty, getCurrentPost, getPostEdits, - getEditedPostStatus, getEditedPostTitle, getEditedPostExcerpt, getEditedPostVisibility, @@ -24,8 +23,10 @@ import { getBlocks, getSelectedBlock, getSelectedBlocks, + getBlockSelectionStart, + getBlockSelectionEnd, getBlockUids, - getBlockOrder, + getBlockIndex, isFirstBlock, isLastBlock, getPreviousBlock, @@ -41,6 +42,7 @@ import { didPostSaveRequestSucceed, didPostSaveRequestFail, isSavingNewPost, + getSuggestedPostFormat, } from '../selectors'; describe( 'selectors', () => { @@ -192,34 +194,6 @@ describe( 'selectors', () => { } ); } ); - describe( 'getEditedPostStatus', () => { - it( 'should return the post saved status if the status is not edited', () => { - const state = { - currentPost: { - status: 'draft', - }, - editor: { - edits: { title: 'chicken' }, - }, - }; - - expect( getEditedPostStatus( state ) ).to.equal( 'draft' ); - } ); - - it( 'should return the edited status', () => { - const state = { - currentPost: { - status: 'draft', - }, - editor: { - edits: { status: 'pending' }, - }, - }; - - expect( getEditedPostStatus( state ) ).to.equal( 'pending' ); - } ); - } ); - describe( 'getEditedPostTitle', () => { it( 'should return the post saved title if the title is not edited', () => { const state = { @@ -459,6 +433,54 @@ describe( 'selectors', () => { } ); } ); + describe( 'getBlockSelectionStart', () => { + it( 'returns null if there is no multi selection', () => { + const state = { + editor: { + blockOrder: [ 123, 23 ], + }, + multiSelectedBlocks: { start: null, end: null }, + }; + + expect( getBlockSelectionStart( state ) ).to.be.null(); + } ); + + it( 'returns multi selection start', () => { + const state = { + editor: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + multiSelectedBlocks: { start: 2, end: 4 }, + }; + + expect( getBlockSelectionStart( state ) ).to.equal( 2 ); + } ); + } ); + + describe( 'getBlockSelectionEnd', () => { + it( 'returns null if there is no multi selection', () => { + const state = { + editor: { + blockOrder: [ 123, 23 ], + }, + multiSelectedBlocks: { start: null, end: null }, + }; + + expect( getBlockSelectionEnd( state ) ).to.be.null(); + } ); + + it( 'returns multi selection end', () => { + const state = { + editor: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + multiSelectedBlocks: { start: 2, end: 4 }, + }; + + expect( getBlockSelectionEnd( state ) ).to.equal( 4 ); + } ); + } ); + describe( 'getBlockUids', () => { it( 'should return the ordered block UIDs', () => { const state = { @@ -471,7 +493,7 @@ describe( 'selectors', () => { } ); } ); - describe( 'getBlockOrder', () => { + describe( 'getBlockIndex', () => { it( 'should return the block order', () => { const state = { editor: { @@ -479,7 +501,7 @@ describe( 'selectors', () => { }, }; - expect( getBlockOrder( state, 23 ) ).to.equal( 1 ); + expect( getBlockIndex( state, 23 ) ).to.equal( 1 ); } ); } ); @@ -839,4 +861,57 @@ describe( 'selectors', () => { expect( isSavingNewPost( state ) ).to.be.false(); } ); } ); + + describe( 'getSuggestedPostFormat', () => { + it( 'returns null if cannot be determined', () => { + const state = { + editor: { + blockOrder: [], + blocksByUid: {}, + }, + }; + + expect( getSuggestedPostFormat( state ) ).to.be.null(); + } ); + + it( 'returns null if there is more than one block in the post', () => { + const state = { + editor: { + blockOrder: [ 123, 456 ], + blocksByUid: { + 123: { uid: 123, blockType: 'core/image' }, + 456: { uid: 456, blockType: 'core/quote' }, + }, + }, + }; + + expect( getSuggestedPostFormat( state ) ).to.be.null(); + } ); + + it( 'returns Image if the first block is of type `core/image`', () => { + const state = { + editor: { + blockOrder: [ 123 ], + blocksByUid: { + 123: { uid: 123, blockType: 'core/image' }, + }, + }, + }; + + expect( getSuggestedPostFormat( state ) ).to.equal( 'Image' ); + } ); + + it( 'returns Quote if the first block is of type `core/quote`', () => { + const state = { + editor: { + blockOrder: [ 456 ], + blocksByUid: { + 456: { uid: 456, blockType: 'core/quote' }, + }, + }, + }; + + expect( getSuggestedPostFormat( state ) ).to.equal( 'Quote' ); + } ); + } ); } );