From 4fa0f08f24bc6fbd43dfc7bb05046db6269bd3e7 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 16 Nov 2017 16:55:45 -0500 Subject: [PATCH] Framework: Adopt Ducks pattern for state shape --- editor/components/post-permalink/index.js | 4 +- .../components/post-preview-button/index.js | 8 +- editor/components/post-saved-state/index.js | 4 +- editor/components/post-trash/index.js | 4 +- editor/sidebar/discussion-panel/index.js | 2 +- editor/sidebar/post-trash/index.js | 4 +- editor/state/actions.js | 647 +---- editor/state/block-insertion-point.js | 150 ++ editor/state/block-selection.js | 404 +++ editor/state/blocks-mode.js | 55 + editor/state/current-post.js | 180 ++ editor/state/editor.js | 861 +++++++ editor/state/effects.js | 14 +- editor/state/hovered-block.js | 45 + editor/state/index.js | 35 +- editor/state/is-typing.js | 62 + editor/state/meta-boxes.js | 222 ++ editor/state/notices.js | 97 + editor/state/panel.js | 51 + editor/state/preferences.js | 258 ++ editor/state/reducer.js | 734 ------ editor/state/reusable-blocks.js | 173 ++ editor/state/saving.js | 100 + editor/state/selectors.js | 1169 +-------- editor/state/test/actions.js | 166 -- editor/state/test/block-insertion-point.js | 205 ++ editor/state/test/block-selection.js | 500 ++++ editor/state/test/blocks-mode.js | 55 + editor/state/test/current-post.js | 234 ++ editor/state/test/editor.js | 1453 +++++++++++ editor/state/test/effects.js | 12 +- editor/state/test/hovered-block.js | 73 + editor/state/test/is-typing.js | 66 + editor/state/test/meta-boxes.js | 339 +++ editor/state/test/notices.js | 74 + editor/state/test/preferences.js | 289 +++ editor/state/test/reducer.js | 1390 ----------- editor/state/test/reusable-blocks.js | 344 +++ editor/state/test/saving.js | 120 + editor/state/test/selectors.js | 2190 ----------------- editor/state/test/ui.js | 97 + editor/state/test/utils.js | 20 + editor/state/ui.js | 28 + editor/state/utils.js | 14 + 44 files changed, 6792 insertions(+), 6160 deletions(-) create mode 100644 editor/state/block-insertion-point.js create mode 100644 editor/state/block-selection.js create mode 100644 editor/state/blocks-mode.js create mode 100644 editor/state/current-post.js create mode 100644 editor/state/editor.js create mode 100644 editor/state/hovered-block.js create mode 100644 editor/state/is-typing.js create mode 100644 editor/state/meta-boxes.js create mode 100644 editor/state/notices.js create mode 100644 editor/state/panel.js create mode 100644 editor/state/preferences.js delete mode 100644 editor/state/reducer.js create mode 100644 editor/state/reusable-blocks.js create mode 100644 editor/state/saving.js delete mode 100644 editor/state/test/actions.js create mode 100644 editor/state/test/block-insertion-point.js create mode 100644 editor/state/test/block-selection.js create mode 100644 editor/state/test/blocks-mode.js create mode 100644 editor/state/test/current-post.js create mode 100644 editor/state/test/editor.js create mode 100644 editor/state/test/hovered-block.js create mode 100644 editor/state/test/is-typing.js create mode 100644 editor/state/test/meta-boxes.js create mode 100644 editor/state/test/notices.js create mode 100644 editor/state/test/preferences.js delete mode 100644 editor/state/test/reducer.js create mode 100644 editor/state/test/reusable-blocks.js create mode 100644 editor/state/test/saving.js delete mode 100644 editor/state/test/selectors.js create mode 100644 editor/state/test/ui.js create mode 100644 editor/state/test/utils.js create mode 100644 editor/state/ui.js create mode 100644 editor/state/utils.js diff --git a/editor/components/post-permalink/index.js b/editor/components/post-permalink/index.js index 7a40db52b60e42..496f948e685543 100644 --- a/editor/components/post-permalink/index.js +++ b/editor/components/post-permalink/index.js @@ -14,7 +14,7 @@ import { Dashicon, ClipboardButton, Button } from '@wordpress/components'; * Internal Dependencies */ import './style.scss'; -import { isEditedPostNew, getEditedPostAttribute } from '../../state/selectors'; +import { isCurrentPostNew, getEditedPostAttribute } from '../../state/selectors'; class PostPermalink extends Component { constructor() { @@ -66,7 +66,7 @@ class PostPermalink extends Component { export default connect( ( state ) => { return { - isNew: isEditedPostNew( state ), + isNew: isCurrentPostNew( state ), link: getEditedPostAttribute( state, 'link' ), }; } diff --git a/editor/components/post-preview-button/index.js b/editor/components/post-preview-button/index.js index 25753f0c39732f..5f95c3384cb899 100644 --- a/editor/components/post-preview-button/index.js +++ b/editor/components/post-preview-button/index.js @@ -14,10 +14,10 @@ import { _x } from '@wordpress/i18n'; * Internal dependencies */ import { - getEditedPostPreviewLink, + getCurrentPostPreviewLink, getEditedPostAttribute, isEditedPostDirty, - isEditedPostNew, + isCurrentPostNew, isEditedPostSaveable, } from '../../state/selectors'; import { autosave } from '../../state/actions'; @@ -117,9 +117,9 @@ export class PostPreviewButton extends Component { export default connect( ( state ) => ( { postId: state.currentPost.id, - link: getEditedPostPreviewLink( state ), + link: getCurrentPostPreviewLink( state ), isDirty: isEditedPostDirty( state ), - isNew: isEditedPostNew( state ), + isNew: isCurrentPostNew( state ), isSaveable: isEditedPostSaveable( state ), modified: getEditedPostAttribute( state, 'modified' ), } ), diff --git a/editor/components/post-saved-state/index.js b/editor/components/post-saved-state/index.js index dff53684b7dbb4..baa6f1b727772d 100644 --- a/editor/components/post-saved-state/index.js +++ b/editor/components/post-saved-state/index.js @@ -16,7 +16,7 @@ import { Dashicon, Button } from '@wordpress/components'; import './style.scss'; import { editPost, savePost } from '../../state/actions'; import { - isEditedPostNew, + isCurrentPostNew, isCurrentPostPublished, isEditedPostDirty, isSavingPost, @@ -68,7 +68,7 @@ export function PostSavedState( { isNew, isPublished, isDirty, isSaving, isSavea export default connect( ( state ) => ( { post: getCurrentPost( state ), - isNew: isEditedPostNew( state ), + isNew: isCurrentPostNew( state ), isPublished: isCurrentPostPublished( state ), isDirty: isEditedPostDirty( state ), isSaving: isSavingPost( state ), diff --git a/editor/components/post-trash/index.js b/editor/components/post-trash/index.js index bb92e06a364ac4..437527a4df2626 100644 --- a/editor/components/post-trash/index.js +++ b/editor/components/post-trash/index.js @@ -14,7 +14,7 @@ import { Button, Dashicon } from '@wordpress/components'; */ import './style.scss'; import { - isEditedPostNew, + isCurrentPostNew, getCurrentPostId, getCurrentPostType, } from '../../state/selectors'; @@ -38,7 +38,7 @@ function PostTrash( { isNew, postId, postType, ...props } ) { export default connect( ( state ) => { return { - isNew: isEditedPostNew( state ), + isNew: isCurrentPostNew( state ), postId: getCurrentPostId( state ), postType: getCurrentPostType( state ), }; diff --git a/editor/sidebar/discussion-panel/index.js b/editor/sidebar/discussion-panel/index.js index 9f36d88e3762b3..8d95a5950f0cdc 100644 --- a/editor/sidebar/discussion-panel/index.js +++ b/editor/sidebar/discussion-panel/index.js @@ -13,7 +13,7 @@ import { PanelBody, PanelRow } from '@wordpress/components'; * Internal Dependencies */ import { PostComments, PostPingbacks } from '../../components'; -import { isEditorSidebarPanelOpened } from '../../state/selectors'; +import { isEditorSidebarPanelOpened } from '../../state/preferences'; import { toggleSidebarPanel } from '../../state/actions'; /** diff --git a/editor/sidebar/post-trash/index.js b/editor/sidebar/post-trash/index.js index 6d296f9393b216..6aa1c178b8eb01 100644 --- a/editor/sidebar/post-trash/index.js +++ b/editor/sidebar/post-trash/index.js @@ -13,7 +13,7 @@ import { PanelRow } from '@wordpress/components'; */ import './style.scss'; import { PostTrash as PostTrashLink } from '../../components'; -import { isEditedPostNew, getCurrentPostId } from '../../state/selectors'; +import { isCurrentPostNew, getCurrentPostId } from '../../state/selectors'; function PostTrash( { isNew, postId } ) { if ( isNew || ! postId ) { @@ -30,7 +30,7 @@ function PostTrash( { isNew, postId } ) { export default connect( ( state ) => { return { - isNew: isEditedPostNew( state ), + isNew: isCurrentPostNew( state ), postId: getCurrentPostId( state ), }; }, diff --git a/editor/state/actions.js b/editor/state/actions.js index a35fb7ba337ff2..1020353226407a 100644 --- a/editor/state/actions.js +++ b/editor/state/actions.js @@ -1,578 +1,69 @@ -/** - * External Dependencies - */ -import uuid from 'uuid/v4'; -import { partial, castArray } from 'lodash'; - -/** - * Returns an action object used in signalling that editor has initialized with - * the specified post object. - * - * @param {Object} post Post object - * @return {Object} Action object - */ -export function setupEditor( post ) { - return { - type: 'SETUP_EDITOR', - post, - }; -} - -/** - * Returns an action object used in signalling that the latest version of the - * post has been received, either by initialization or save. - * - * @param {Object} post Post object - * @return {Object} Action object - */ -export function resetPost( post ) { - return { - type: 'RESET_POST', - post, - }; -} - -/** - * Returns an action object used in signalling that editor has initialized as a - * new post with specified edits which should be considered non-dirtying. - * - * @param {Object} edits Edited attributes object - * @return {Object} Action object - */ -export function setupNewPost( edits ) { - return { - type: 'SETUP_NEW_POST', - edits, - }; -} - -/** - * Returns an action object used in signalling that blocks state should be - * reset to the specified array of blocks, taking precedence over any other - * content reflected as an edit in state. - * - * @param {Array} blocks Array of blocks - * @return {Object} Action object - */ -export function resetBlocks( blocks ) { - return { - type: 'RESET_BLOCKS', - blocks, - }; -} - -/** - * Returns an action object used in signalling that the block attributes with the - * specified UID has been updated. - * - * @param {String} uid Block UID - * @param {Object} attributes Block attributes to be merged - * @return {Object} Action object - */ -export function updateBlockAttributes( uid, attributes ) { - return { - type: 'UPDATE_BLOCK_ATTRIBUTES', - uid, - attributes, - }; -} - -/** - * Returns an action object used in signalling that the block with the - * specified UID has been updated. - * - * @param {String} uid Block UID - * @param {Object} updates Block attributes to be merged - * @return {Object} Action object - */ -export function updateBlock( uid, updates ) { - return { - type: 'UPDATE_BLOCK', - uid, - updates, - }; -} - -export function focusBlock( uid, config ) { - return { - type: 'UPDATE_FOCUS', - uid, - config, - }; -} - -export function selectBlock( uid ) { - return { - type: 'SELECT_BLOCK', - uid, - }; -} - -export function startMultiSelect() { - return { - type: 'START_MULTI_SELECT', - }; -} - -export function stopMultiSelect() { - return { - type: 'STOP_MULTI_SELECT', - }; -} - -export function multiSelect( start, end ) { - return { - type: 'MULTI_SELECT', - start, - end, - }; -} - -export function clearSelectedBlock() { - return { - type: 'CLEAR_SELECTED_BLOCK', - }; -} - -/** - * Returns an action object signalling that a blocks should be replaced with - * one or more replacement blocks. - * - * @param {(String|String[])} uids Block UID(s) to replace - * @param {(Object|Object[])} blocks Replacement block(s) - * @return {Object} Action object - */ -export function replaceBlocks( uids, blocks ) { - return { - type: 'REPLACE_BLOCKS', - uids: castArray( uids ), - blocks: castArray( blocks ), - }; -} - -/** - * Returns an action object signalling that a single block should be replaced - * with one or more replacement blocks. - * - * @param {(String|String[])} uid Block UID(s) to replace - * @param {(Object|Object[])} block Replacement block(s) - * @return {Object} Action object - */ -export function replaceBlock( uid, block ) { - return replaceBlocks( uid, block ); -} - -export function insertBlock( block, position ) { - return insertBlocks( [ block ], position ); -} - -export function insertBlocks( blocks, position ) { - return { - type: 'INSERT_BLOCKS', - blocks: castArray( blocks ), - position, - }; -} - -export function showInsertionPoint() { - return { - type: 'SHOW_INSERTION_POINT', - }; -} - -export function hideInsertionPoint() { - return { - type: 'HIDE_INSERTION_POINT', - }; -} - -/** - * Returns an action object used in signalling that block insertion should - * occur at the specified block index position. - * - * @param {Number} position Position at which to insert - * @return {Object} Action object - */ -export function setBlockInsertionPoint( position ) { - return { - type: 'SET_BLOCK_INSERTION_POINT', - position, - }; -} - -/** - * Returns an action object used in signalling that the block insertion point - * should be reset. - * - * @return {Object} Action object - */ -export function clearBlockInsertionPoint() { - return { - type: 'CLEAR_BLOCK_INSERTION_POINT', - }; -} - -export function editPost( edits ) { - return { - type: 'EDIT_POST', - edits, - }; -} - -export function savePost() { - return { - type: 'REQUEST_POST_UPDATE', - }; -} - -export function trashPost( postId, postType ) { - return { - type: 'TRASH_POST', - postId, - postType, - }; -} - -export function mergeBlocks( blockA, blockB ) { - return { - type: 'MERGE_BLOCKS', - blocks: [ blockA, blockB ], - }; -} - -/** - * Returns an action object used in signalling that the post should autosave. - * - * @return {Object} Action object - */ -export function autosave() { - return { - type: 'AUTOSAVE', - }; -} - -/** - * Returns an action object used in signalling that undo history should - * restore last popped state. - * - * @return {Object} Action object - */ -export function redo() { - return { type: 'REDO' }; -} - -/** - * Returns an action object used in signalling that undo history should pop. - * - * @return {Object} Action object - */ -export function undo() { - return { type: 'UNDO' }; -} - -/** - * Returns an action object used in signalling that the blocks - * corresponding to the specified UID set are to be removed. - * - * @param {String[]} uids Block UIDs - * @return {Object} Action object - */ -export function removeBlocks( uids ) { - return { - type: 'REMOVE_BLOCKS', - uids, - }; -} - -/** - * Returns an action object used in signalling that the block with the - * specified UID is to be removed. - * - * @param {String} uid Block UID - * @return {Object} Action object - */ -export function removeBlock( uid ) { - return removeBlocks( [ uid ] ); -} - -/** - * Returns an action object used to toggle the block editing mode (visual/html) - * - * @param {String} uid Block UID - * @return {Object} Action object - */ -export function toggleBlockMode( uid ) { - return { - type: 'TOGGLE_BLOCK_MODE', - uid, - }; -} - -/** - * Returns an action object used in signalling that the user has begun to type. - * - * @return {Object} Action object - */ -export function startTyping() { - return { - type: 'START_TYPING', - }; -} - -/** - * Returns an action object used in signalling that the user has stopped typing. - * - * @return {Object} Action object - */ -export function stopTyping() { - return { - type: 'STOP_TYPING', - }; -} - -/** - * Returns an action object used in signalling that the user toggled the sidebar - * - * @return {Object} Action object - */ -export function toggleSidebar() { - return { - type: 'TOGGLE_SIDEBAR', - }; -} - -/** - * Returns an action object used in signalling that the user switched the active sidebar tab panel - * - * @param {String} panel The panel name - * @return {Object} Action object - */ -export function setActivePanel( panel ) { - return { - type: 'SET_ACTIVE_PANEL', - panel, - }; -} - -/** - * Returns an action object used in signalling that the user toggled a sidebar panel - * - * @param {String} panel The panel name - * @return {Object} Action object - */ -export function toggleSidebarPanel( panel ) { - return { - type: 'TOGGLE_SIDEBAR_PANEL', - panel, - }; -} - -/** - * Returns an action object used to create a notice - * - * @param {String} status The notice status - * @param {WPElement} content The notice content - * @param {?Object} options The notice options. Available options: - * `id` (string; default auto-generated) - * `isDismissible` (boolean; default `true`) - * - * @return {Object} Action object - */ -export function createNotice( status, content, options = {} ) { - const { - id = uuid(), - isDismissible = true, - } = options; - return { - type: 'CREATE_NOTICE', - notice: { - id, - status, - content, - isDismissible, - }, - }; -} - -/** - * Returns an action object used to remove a notice - * - * @param {String} id The notice id - * - * @return {Object} Action object - */ -export function removeNotice( id ) { - return { - type: 'REMOVE_NOTICE', - noticeId: id, - }; -} - -/** - * Returns an action object used to check the state of meta boxes at a location. - * - * This should only be fired once to initialize meta box state. If a meta box - * area is empty, this will set the store state to indicate that React should - * not render the meta box area. - * - * Example: metaBoxes = { side: true, normal: false } - * This indicates that the sidebar has a meta box but the normal area does not. - * - * @param {Object} metaBoxes Whether meta box locations are active. - * - * @return {Object} Action object - */ -export function initializeMetaBoxState( metaBoxes ) { - return { - type: 'INITIALIZE_META_BOX_STATE', - metaBoxes, - }; -} - -/** - * Returns an action object used to signify that a meta box finished reloading. - * - * @param {String} location Location of meta box: 'normal', 'side'. - * - * @return {Object} Action object - */ -export function handleMetaBoxReload( location ) { - return { - type: 'HANDLE_META_BOX_RELOAD', - location, - }; -} - -/** - * Returns an action object used to signify that a meta box finished loading. - * - * @param {String} location Location of meta box: 'normal', 'side'. - * - * @return {Object} Action object - */ -export function metaBoxLoaded( location ) { - return { - type: 'META_BOX_LOADED', - location, - }; -} - -/** - * Returns an action object used to request meta box update. - * - * @param {Array} locations Locations of meta boxes: ['normal', 'side' ]. - * - * @return {Object} Action object - */ -export function requestMetaBoxUpdates( locations ) { - return { - type: 'REQUEST_META_BOX_UPDATES', - locations, - }; -} - -/** - * Returns an action object used to set meta box state changed. - * - * @param {String} location Location of meta box: 'normal', 'side'. - * @param {Boolean} hasChanged Whether the meta box has changed. - * - * @return {Object} Action object - */ -export function metaBoxStateChanged( location, hasChanged ) { - return { - type: 'META_BOX_STATE_CHANGED', - location, - hasChanged, - }; -} - -/** - * Returns an action object used to toggle a feature flag - * - * @param {String} feature Featurre name. - * - * @return {Object} Action object - */ -export function toggleFeature( feature ) { - return { - type: 'TOGGLE_FEATURE', - feature, - }; -} - -export const createSuccessNotice = partial( createNotice, 'success' ); -export const createInfoNotice = partial( createNotice, 'info' ); -export const createErrorNotice = partial( createNotice, 'error' ); -export const createWarningNotice = partial( createNotice, 'warning' ); - -/** - * Returns an action object used to fetch a single reusable block or all - * reusable blocks from the REST API into the store. - * - * @param {?string} id If given, only a single reusable block with this ID will be fetched - * @return {Object} Action object - */ -export function fetchReusableBlocks( id ) { - return { - type: 'FETCH_REUSABLE_BLOCKS', - id, - }; -} - -/** - * Returns an action object used to insert or update a reusable block into the store. - * - * @param {Object} id The ID of the reusable block to update - * @param {Object} reusableBlock The new reusable block object. Any omitted keys are not changed - * @return {Object} Action object - */ -export function updateReusableBlock( id, reusableBlock ) { - return { - type: 'UPDATE_REUSABLE_BLOCK', - id, - reusableBlock, - }; -} - -/** - * Returns an action object used to save a reusable block that's in the store - * to the REST API. - * - * @param {Object} id The ID of the reusable block to save - * @return {Object} Action object - */ -export function saveReusableBlock( id ) { - return { - type: 'SAVE_REUSABLE_BLOCK', - id, - }; -} - -/** - * Returns an action object used to convert a reusable block into a static - * block. - * - * @param {Object} uid The ID of the block to attach - * @return {Object} Action object - */ -export function convertBlockToStatic( uid ) { - return { - type: 'CONVERT_BLOCK_TO_STATIC', - uid, - }; -} - -/** - * Returns an action object used to convert a static block into a reusable - * block. - * - * @param {Object} uid The ID of the block to detach - * @return {Object} Action object - */ -export function convertBlockToReusable( uid ) { - return { - type: 'CONVERT_BLOCK_TO_REUSABLE', - uid, - }; -} +export { + showInsertionPoint, + hideInsertionPoint, + setBlockInsertionPoint, + clearBlockInsertionPoint, +} from './block-insertion-point'; +export { + focusBlock, + selectBlock, + clearSelectedBlock, + startMultiSelect, + stopMultiSelect, + multiSelect, +} from './block-selection'; +export { + toggleBlockMode, +} from './blocks-mode'; +export { + resetPost, + trashPost, +} from './current-post'; +export { + setupEditor, + setupNewPost, + resetBlocks, + editPost, + updateBlockAttributes, + updateBlock, + replaceBlocks, + replaceBlock, + insertBlock, + insertBlocks, + mergeBlocks, + removeBlocks, + removeBlock, + redo, + undo, +} from './editor'; +export { + startTyping, + stopTyping, +} from './is-typing'; +export { + initializeMetaBoxState, + handleMetaBoxReload, + metaBoxLoaded, + requestMetaBoxUpdates, + metaBoxStateChanged, +} from './meta-boxes'; +export { + createNotice, + createSuccessNotice, + createInfoNotice, + createErrorNotice, + createWarningNotice, + removeNotice, +} from './notices'; +export { + setActivePanel, +} from './panel'; +export { + toggleSidebar, + toggleSidebarPanel, + toggleFeature, +} from './preferences'; +export { + savePost, + autosave, +} from './saving'; diff --git a/editor/state/block-insertion-point.js b/editor/state/block-insertion-point.js new file mode 100644 index 00000000000000..5ce7f28c3c59be --- /dev/null +++ b/editor/state/block-insertion-point.js @@ -0,0 +1,150 @@ +/** + * Internal dependencies + */ +import { getBlockIndex } from './editor'; +import { getLastMultiSelectedBlockUid, getSelectedBlock } from './block-selection'; +import { getEditorMode } from './preferences'; + +/** + * Reducer + */ + +/** + * Reducer returning the block insertion point + * + * @param {Object} state Current state + * @param {Object} action Dispatched action + * @return {Object} Updated state + */ +export default function blockInsertionPoint( state = {}, action ) { + switch ( action.type ) { + case 'SET_BLOCK_INSERTION_POINT': + const { position } = action; + return { ...state, position }; + + case 'CLEAR_BLOCK_INSERTION_POINT': + return { ...state, position: null }; + + case 'SHOW_INSERTION_POINT': + return { ...state, visible: true }; + + case 'HIDE_INSERTION_POINT': + return { ...state, visible: false }; + } + + return state; +} + +/** + * Action creators + */ + +/** + * Returns an action object used in signalling that the block insertion point + * should be made visible. + * + * @return {Object} Action object + */ +export function showInsertionPoint() { + return { + type: 'SHOW_INSERTION_POINT', + }; +} + +/** + * Returns an action object used in signalling that the block insertion point + * should be hidden. + * + * @return {Object} Action object + */ +export function hideInsertionPoint() { + return { + type: 'HIDE_INSERTION_POINT', + }; +} + +/** + * Returns an action object used in signalling that block insertion should + * occur at the specified block index position. + * + * @param {Number} position Position at which to insert + * @return {Object} Action object + */ +export function setBlockInsertionPoint( position ) { + return { + type: 'SET_BLOCK_INSERTION_POINT', + position, + }; +} + +/** + * Returns an action object used in signalling that the block insertion point + * should be reset. + * + * @return {Object} Action object + */ +export function clearBlockInsertionPoint() { + return { + type: 'CLEAR_BLOCK_INSERTION_POINT', + }; +} + +/** + * Selectors + */ + +/** + * Returns the insertion point, the index at which the new inserted block would + * be placed. Defaults to the last position + * + * @param {Object} state Global application state + * @return {?String} Unique ID after which insertion will occur + */ +export function getBlockInsertionPoint( state ) { + if ( getEditorMode( state ) !== 'visual' ) { + return state.editor.present.blockOrder.length; + } + + const position = getBlockSiblingInserterPosition( state ); + if ( null !== position ) { + return position; + } + + const lastMultiSelectedBlock = getLastMultiSelectedBlockUid( state ); + if ( lastMultiSelectedBlock ) { + return getBlockIndex( state, lastMultiSelectedBlock ) + 1; + } + + const selectedBlock = getSelectedBlock( state ); + if ( selectedBlock ) { + return getBlockIndex( state, selectedBlock.uid ) + 1; + } + + return state.editor.present.blockOrder.length; +} + +/** + * Returns the position at which the block inserter will insert a new adjacent + * sibling block, or null if the inserter is not actively visible. + * + * @param {Object} state Global application state + * @return {?Number} Whether the inserter is currently visible + */ +export function getBlockSiblingInserterPosition( state ) { + const { position } = state.blockInsertionPoint; + if ( ! Number.isInteger( position ) ) { + return null; + } + + return position; +} + +/** + * Returns true if we should show the block insertion point + * + * @param {Object} state Global application state + * @return {?Boolean} Whether the insertion point is visible or not + */ +export function isBlockInsertionPointVisible( state ) { + return !! state.blockInsertionPoint.visible; +} diff --git a/editor/state/block-selection.js b/editor/state/block-selection.js new file mode 100644 index 00000000000000..4a93b3dcdc2fdc --- /dev/null +++ b/editor/state/block-selection.js @@ -0,0 +1,404 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; +import { first, last } from 'lodash'; + +/** + * Internal dependencies + */ +import { getBlock } from './editor'; + +/** + * Reducer + */ + +/** + * Reducer returning the block selection's state. + * + * @param {Object} state Current state + * @param {Object} action Dispatched action + * @return {Object} Updated state + */ +export default function blockSelection( state = { start: null, end: null, focus: null, isMultiSelecting: false }, action ) { + switch ( action.type ) { + case 'CLEAR_SELECTED_BLOCK': + return { + start: null, + end: null, + focus: null, + isMultiSelecting: false, + }; + case 'START_MULTI_SELECT': + return { + ...state, + isMultiSelecting: true, + }; + case 'STOP_MULTI_SELECT': + return { + ...state, + isMultiSelecting: false, + focus: state.start === state.end ? state.focus : null, + }; + case 'MULTI_SELECT': + return { + ...state, + start: action.start, + end: action.end, + focus: state.isMultiSelecting ? state.focus : null, + }; + case 'SELECT_BLOCK': + if ( action.uid === state.start && action.uid === state.end ) { + return state; + } + return { + ...state, + start: action.uid, + end: action.uid, + focus: action.focus || {}, + }; + case 'UPDATE_FOCUS': + return { + ...state, + start: action.uid, + end: action.uid, + focus: action.config || {}, + }; + case 'INSERT_BLOCKS': + return { + start: action.blocks[ 0 ].uid, + end: action.blocks[ 0 ].uid, + focus: {}, + isMultiSelecting: false, + }; + case 'REPLACE_BLOCKS': + if ( ! action.blocks || ! action.blocks.length || action.uids.indexOf( state.start ) === -1 ) { + return state; + } + return { + start: action.blocks[ 0 ].uid, + end: action.blocks[ 0 ].uid, + focus: {}, + isMultiSelecting: false, + }; + } + + return state; +} + +/** + * Action creators + */ + +/** + * Returns an action object signalling that the focus state for a block by the + * specified UID should be assigned. + * + * @param {String} uid Block UID + * @param {Object} config Focus configuration + * @return {Object} Action object + */ +export function focusBlock( uid, config ) { + return { + type: 'UPDATE_FOCUS', + uid, + config, + }; +} + +/** + * Returns an action object signalling that a block should be selected. + * + * @param {String} uid Block UID + * @return {Object} Action object + */ +export function selectBlock( uid ) { + return { + type: 'SELECT_BLOCK', + uid, + }; +} + +/** + * Returns an action object signalling that the currently selected block should + * be reset. + * + * @return {Object} Action object + */ +export function clearSelectedBlock() { + return { + type: 'CLEAR_SELECTED_BLOCK', + }; +} + +/** + * Returns an action object signalling that block multi-selection has started. + * + * @return {Object} Action object + */ +export function startMultiSelect() { + return { + type: 'START_MULTI_SELECT', + }; +} + +/** + * Returns an action object signalling that block multi-selection has stopped. + * + * @return {Object} Action object + */ +export function stopMultiSelect() { + return { + type: 'STOP_MULTI_SELECT', + }; +} + +/** + * Returns an action object signalling that multi-selection should be applied + * to the specified start and end offsets. + * + * @param {Number} start Start offset for multi-selection + * @param {Number} end End offset for multi-selection + * @return {Object} Action object + */ +export function multiSelect( start, end ) { + return { + type: 'MULTI_SELECT', + start, + end, + }; +} + +/** + * Selectors + */ + +/** + * 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 { start, end } = state.blockSelection; + if ( start !== end || ! start ) { + return null; + } + + return getBlock( state, start ); +} + +/** + * Returns the number of blocks currently selected in the post. + * + * @param {Object} state Global application state + * @return {Number} Number of blocks selected in the post + */ +export function getSelectedBlockCount( state ) { + const multiSelectedBlockCount = getMultiSelectedBlockUids( state ).length; + + if ( multiSelectedBlockCount ) { + return multiSelectedBlockCount; + } + + return state.blockSelection.start ? 1 : 0; +} + +/** + * Returns the current multi-selection set of blocks unique IDs, or an empty + * array if there is no multi-selection. + * + * @param {Object} state Global application state + * @return {Array} Multi-selected block unique UDs + */ +export const getMultiSelectedBlockUids = createSelector( + ( state ) => { + const { blockOrder } = state.editor.present; + const { start, end } = state.blockSelection; + if ( start === end ) { + return []; + } + + const startIndex = blockOrder.indexOf( start ); + const endIndex = blockOrder.indexOf( end ); + + if ( startIndex > endIndex ) { + return blockOrder.slice( endIndex, startIndex + 1 ); + } + + return blockOrder.slice( startIndex, endIndex + 1 ); + }, + ( state ) => [ + state.editor.present.blockOrder, + state.blockSelection.start, + state.blockSelection.end, + ], +); + +/** + * 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 const getMultiSelectedBlocks = createSelector( + ( state ) => getMultiSelectedBlockUids( state ).map( ( uid ) => getBlock( state, uid ) ), + ( state ) => [ + state.editor.present.blockOrder, + state.blockSelection.start, + state.blockSelection.end, + state.editor.present.blocksByUid, + state.editor.present.edits.meta, + state.currentPost.meta, + ] +); + +/** + * Returns the unique ID of the first block in the multi-selection set, or null + * if there is no multi-selection. + * + * @param {Object} state Global application state + * @return {?String} First unique block ID in the multi-selection set + */ +export function getFirstMultiSelectedBlockUid( state ) { + return first( getMultiSelectedBlockUids( state ) ) || null; +} + +/** + * Returns the unique ID of the last block in the multi-selection set, or null + * if there is no multi-selection. + * + * @param {Object} state Global application state + * @return {?String} Last unique block ID in the multi-selection set + */ +export function getLastMultiSelectedBlockUid( state ) { + return last( getMultiSelectedBlockUids( state ) ) || null; +} + +/** + * 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 {String} uid Block unique ID + * @return {Boolean} Whether block is first in mult-selection + */ +export function isFirstMultiSelectedBlock( state, uid ) { + return getFirstMultiSelectedBlockUid( state ) === uid; +} + +/** + * Returns true if the unique ID occurs within the block multi-selection, or + * false otherwise. + * + * @param {Object} state Global application state + * @param {String} uid Block unique ID + * @return {Boolean} Whether block is in multi-selection set + */ +export function isBlockMultiSelected( state, uid ) { + return getMultiSelectedBlockUids( state ).indexOf( uid ) !== -1; +} + +/** + * Returns the unique ID of the block which begins the multi-selection set, or + * null if there is no multi-selection. + * + * N.b.: This is not necessarily the first uid in the selection. See + * getFirstMultiSelectedBlockUid(). + * + * @param {Object} state Global application state + * @return {?String} Unique ID of block beginning multi-selection + */ +export function getMultiSelectedBlocksStartUid( state ) { + const { start, end } = state.blockSelection; + if ( start === end ) { + return null; + } + return start || null; +} + +/** + * Returns the unique ID of the block which ends the multi-selection set, or + * null if there is no multi-selection. + * + * N.b.: This is not necessarily the last uid in the selection. See + * getLastMultiSelectedBlockUid(). + * + * @param {Object} state Global application state + * @return {?String} Unique ID of block ending multi-selection + */ +export function getMultiSelectedBlocksEndUid( state ) { + const { start, end } = state.blockSelection; + if ( start === end ) { + return null; + } + return end || null; +} + +/** + * Returns true if the block corresponding to the specified unique ID is + * currently selected but isn't the last of the selected blocks. Here "last" + * refers to the block sequence in the document, _not_ the sequence of + * multi-selection, which is why `state.blockSelection.end` isn't used. + * + * @param {Object} state Global application state + * @param {String} uid Block unique ID + * @return {Boolean} Whether block is selected and not the last in the selection + */ +export function isBlockWithinSelection( state, uid ) { + if ( ! uid ) { + return false; + } + + const uids = getMultiSelectedBlockUids( state ); + const index = uids.indexOf( uid ); + return index > -1 && index < uids.length - 1; +} + +/** + * Returns true if the block corresponding to the specified unique ID is + * currently selected and no multi-selection exists, or false otherwise. + * + * @param {Object} state Global application state + * @param {String} uid Block unique ID + * @return {Boolean} Whether block is selected and multi-selection exists + */ +export function isBlockSelected( state, uid ) { + const { start, end } = state.blockSelection; + + if ( start !== end ) { + return false; + } + + return start === 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 {String} uid Block unique ID + * @return {Object} Block focus state + */ +export function getBlockFocus( state, uid ) { + // If there is multi-selection, keep returning the focus object for the start block. + if ( ! isBlockSelected( state, uid ) && state.blockSelection.start !== uid ) { + return null; + } + + return state.blockSelection.focus; +} + +/** + * Whether in the process of multi-selecting or not. + * + * @param {Object} state Global application state + * @return {Boolean} True if multi-selecting, false if not. + */ +export function isMultiSelecting( state ) { + return state.blockSelection.isMultiSelecting; +} diff --git a/editor/state/blocks-mode.js b/editor/state/blocks-mode.js new file mode 100644 index 00000000000000..8ba14e5b63f9cb --- /dev/null +++ b/editor/state/blocks-mode.js @@ -0,0 +1,55 @@ +/** + * Reducer + */ + +/** + * Reducer returning active panel, containing keys of block UID whose values + * reflect whether the block is being edited as visual or HTML. + * + * @param {Object} state Current state + * @param {Object} action Dispatched action + * @return {Object} Updated state + */ +export default function blocksMode( state = {}, action ) { + if ( action.type === 'TOGGLE_BLOCK_MODE' ) { + const { uid } = action; + return { + ...state, + [ uid ]: state[ uid ] && state[ uid ] === 'html' ? 'visual' : 'html', + }; + } + + return state; +} + +/** + * Action creators + */ + +/** + * Returns an action object used to toggle the block editing mode (visual/html) + * + * @param {String} uid Block UID + * @return {Object} Action object + */ +export function toggleBlockMode( uid ) { + return { + type: 'TOGGLE_BLOCK_MODE', + uid, + }; +} + +/** + * Selectors + */ + +/** + * Returns thee block's editing mode + * + * @param {Object} state Global application state + * @param {String} uid Block unique ID + * @return {Object} Block editing mode + */ +export function getBlockMode( state, uid ) { + return state.blocksMode[ uid ] || 'visual'; +} diff --git a/editor/state/current-post.js b/editor/state/current-post.js new file mode 100644 index 00000000000000..36c48ebd21cd29 --- /dev/null +++ b/editor/state/current-post.js @@ -0,0 +1,180 @@ +/** + * External dependencies + */ +import moment from 'moment'; +import { mapValues, get } from 'lodash'; + +/** + * WordPress dependencies + */ +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { getPostRawValue } from './utils'; + +/** + * Reducer + */ + +/** + * Reducer returning the last-known state of the current post, in the format + * returned by the WP REST API. + * + * @param {Object} state Current state + * @param {Object} action Dispatched action + * @return {Object} Updated state + */ +export default function( state = {}, action ) { + switch ( action.type ) { + case 'RESET_POST': + case 'UPDATE_POST': + let post; + if ( action.post ) { + post = action.post; + } else if ( action.edits ) { + post = { + ...state, + ...action.edits, + }; + } else { + return state; + } + + return mapValues( post, getPostRawValue ); + } + + return state; +} + +/** + * Action creators + */ + +/** + * Returns an action object used in signalling that the latest version of the + * post has been received, either by initialization or save. + * + * @param {Object} post Post object + * @return {Object} Action object + */ +export function resetPost( post ) { + return { + type: 'RESET_POST', + post, + }; +} + +/** + * Returns an action object used in signalling that a post should be moved to + * the trash. + * + * @param {Number} postId ID of post to move to trash + * @param {String} postType Type of post to move to trash + * @return {Object} Action object + */ +export function trashPost( postId, postType ) { + return { + type: 'TRASH_POST', + postId, + postType, + }; +} + +/** + * Selectors + */ + +/** + * 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 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 isCurrentPostNew( state ) { + return getCurrentPost( state ).status === 'auto-draft'; +} + +/** + * Returns the ID of the post currently being edited, or null if the post has + * not yet been saved. + * + * @param {Object} state Global application state + * @return {?Number} ID of current post + */ +export function getCurrentPostId( state ) { + return getCurrentPost( state ).id || null; +} + +/** + * Returns the post type of the post currently being edited + * + * @param {Object} state Global application state + * @return {String} Post type + */ +export function getCurrentPostType( state ) { + return state.currentPost.type; +} + +/** + * Returns the number of revisions of the post currently being edited. + * + * @param {Object} state Global application state + * @return {Number} Number of revisions + */ +export function getCurrentPostRevisionsCount( state ) { + return get( getCurrentPost( state ), 'revisions.count', 0 ); +} + +/** + * Returns the last revision ID of the post currently being edited, + * or null if the post has no revisions. + * + * @param {Object} state Global application state + * @return {?Number} ID of the last revision + */ +export function getCurrentPostLastRevisionId( state ) { + return get( getCurrentPost( state ), 'revisions.last_id', null ); +} + +/** + * Return true if the current post has already been published. + * + * @param {Object} state Global application state + * @return {Boolean} Whether the post has been published + */ +export function isCurrentPostPublished( state ) { + const post = getCurrentPost( state ); + + return [ 'publish', 'private' ].indexOf( post.status ) !== -1 || + ( post.status === 'future' && moment( post.date ).isBefore( moment() ) ); +} + +/** + * Returns a URL to preview the post being edited. + * + * @param {Object} state Global application state + * @return {String} Preview URL + */ +export function getCurrentPostPreviewLink( state ) { + const link = state.currentPost.link; + if ( ! link ) { + return null; + } + + return addQueryArgs( link, { preview: 'true' } ); +} diff --git a/editor/state/editor.js b/editor/state/editor.js new file mode 100644 index 00000000000000..98b029e8b905fb --- /dev/null +++ b/editor/state/editor.js @@ -0,0 +1,861 @@ +/** + * External dependencies + */ +import moment from 'moment'; +import createSelector from 'rememo'; +import { combineReducers } from 'redux'; +import { + castArray, + flow, + partialRight, + reduce, + keyBy, + first, + last, + omit, + without, + has, + get, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { getBlockType, serialize } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import withHistory from '../utils/with-history'; +import withChangeDetection from '../utils/with-change-detection'; +import { getPostRawValue } from './utils'; +import { getCurrentPost, isCurrentPostNew } from './current-post'; +import { isMetaBoxStateDirty } from './meta-boxes'; + +/** + * Reducer + */ + +/** + * Undoable reducer returning the editor post state, including blocks parsed + * from current HTML markup. + * + * Handles the following state keys: + * - edits: an object describing changes to be made to the current post, in + * the format accepted by the WP REST API + * - blocksByUid: post content blocks keyed by UID + * - blockOrder: list of block UIDs in order + * + * @param {Object} state Current state + * @param {Object} action Dispatched action + * @return {Object} Updated state + */ +export default flow( [ + combineReducers, + + // Track undo history, starting at editor initialization. + partialRight( withHistory, { resetTypes: [ 'SETUP_EDITOR' ] } ), + + // Track whether changes exist, starting at editor initialization and + // resetting at each post save. + partialRight( withChangeDetection, { resetTypes: [ 'SETUP_EDITOR', 'RESET_POST' ] } ), +] )( { + edits( state = {}, action ) { + switch ( action.type ) { + case 'EDIT_POST': + case 'SETUP_NEW_POST': + return reduce( action.edits, ( result, value, key ) => { + // Only assign into result if not already same value + if ( value !== state[ key ] ) { + // Avoid mutating original state by creating shallow + // clone. Should only occur once per reduce. + if ( result === state ) { + result = { ...state }; + } + + result[ key ] = value; + } + + return result; + }, state ); + + case 'RESET_BLOCKS': + if ( 'content' in state ) { + return omit( state, 'content' ); + } + + return state; + + case 'RESET_POST': + return reduce( state, ( result, value, key ) => { + if ( value !== getPostRawValue( action.post[ key ] ) ) { + return result; + } + + if ( state === result ) { + result = { ...state }; + } + + delete result[ key ]; + return result; + }, state ); + } + + return state; + }, + + blocksByUid( state = {}, action ) { + switch ( action.type ) { + case 'RESET_BLOCKS': + return keyBy( action.blocks, 'uid' ); + + case 'UPDATE_BLOCK_ATTRIBUTES': + // Ignore updates if block isn't known + if ( ! state[ action.uid ] ) { + return state; + } + + // Consider as updates only changed values + const nextAttributes = reduce( action.attributes, ( result, value, key ) => { + if ( value !== result[ key ] ) { + // Avoid mutating original block by creating shallow clone + if ( result === state[ action.uid ].attributes ) { + result = { ...result }; + } + + result[ key ] = value; + } + + return result; + }, state[ action.uid ].attributes ); + + // Skip update if nothing has been changed. The reference will + // match the original block if `reduce` had no changed values. + if ( nextAttributes === state[ action.uid ].attributes ) { + return state; + } + + // Otherwise merge attributes into state + return { + ...state, + [ action.uid ]: { + ...state[ action.uid ], + attributes: nextAttributes, + }, + }; + + case 'UPDATE_BLOCK': + // Ignore updates if block isn't known + if ( ! state[ action.uid ] ) { + return state; + } + + return { + ...state, + [ action.uid ]: { + ...state[ action.uid ], + ...action.updates, + }, + }; + + case 'INSERT_BLOCKS': + return { + ...state, + ...keyBy( action.blocks, 'uid' ), + }; + + case 'REPLACE_BLOCKS': + if ( ! action.blocks ) { + return state; + } + return action.blocks.reduce( ( memo, block ) => { + return { + ...memo, + [ block.uid ]: block, + }; + }, omit( state, action.uids ) ); + + case 'REMOVE_BLOCKS': + return omit( state, action.uids ); + } + + return state; + }, + + blockOrder( state = [], action ) { + switch ( action.type ) { + case 'RESET_BLOCKS': + return action.blocks.map( ( { uid } ) => uid ); + + case 'INSERT_BLOCKS': { + const position = action.position !== undefined ? action.position : state.length; + return [ + ...state.slice( 0, position ), + ...action.blocks.map( block => block.uid ), + ...state.slice( position ), + ]; + } + + case 'MOVE_BLOCKS_UP': { + const firstUid = first( action.uids ); + const lastUid = last( action.uids ); + + if ( ! state.length || firstUid === first( state ) ) { + return state; + } + + const firstIndex = state.indexOf( firstUid ); + const lastIndex = state.indexOf( lastUid ); + const swappedUid = state[ firstIndex - 1 ]; + + return [ + ...state.slice( 0, firstIndex - 1 ), + ...action.uids, + swappedUid, + ...state.slice( lastIndex + 1 ), + ]; + } + + case 'MOVE_BLOCKS_DOWN': { + const firstUid = first( action.uids ); + const lastUid = last( action.uids ); + + if ( ! state.length || lastUid === last( state ) ) { + return state; + } + + const firstIndex = state.indexOf( firstUid ); + const lastIndex = state.indexOf( lastUid ); + const swappedUid = state[ lastIndex + 1 ]; + + return [ + ...state.slice( 0, firstIndex ), + swappedUid, + ...action.uids, + ...state.slice( lastIndex + 2 ), + ]; + } + + case 'REPLACE_BLOCKS': + if ( ! action.blocks ) { + return state; + } + + return state.reduce( ( memo, uid ) => { + if ( uid === action.uids[ 0 ] ) { + return memo.concat( action.blocks.map( ( block ) => block.uid ) ); + } + if ( action.uids.indexOf( uid ) === -1 ) { + memo.push( uid ); + } + return memo; + }, [] ); + + case 'REMOVE_BLOCKS': + return without( state, ...action.uids ); + } + + return state; + }, +} ); + +/** + * Action creators + */ + +/** + * Returns an action object used in signalling that editor has initialized with + * the specified post object. + * + * @param {Object} post Post object + * @return {Object} Action object + */ +export function setupEditor( post ) { + return { + type: 'SETUP_EDITOR', + post, + }; +} + +/** + * Returns an action object used in signalling that editor has initialized as a + * new post with specified edits which should be considered non-dirtying. + * + * @param {Object} edits Edited attributes object + * @return {Object} Action object + */ +export function setupNewPost( edits ) { + return { + type: 'SETUP_NEW_POST', + edits, + }; +} + +/** + * Returns an action object used in signalling that blocks state should be + * reset to the specified array of blocks, taking precedence over any other + * content reflected as an edit in state. + * + * @param {Array} blocks Array of blocks + * @return {Object} Action object + */ +export function resetBlocks( blocks ) { + return { + type: 'RESET_BLOCKS', + blocks, + }; +} + +/** + * Returns an action object signalling that the post has been edited. + * + * @param {Object} edits Edits to apply + * @return {Object} Action object + */ +export function editPost( edits ) { + return { + type: 'EDIT_POST', + edits, + }; +} + +/** + * Returns an action object used in signalling that the block attributes with the + * specified UID has been updated. + * + * @param {String} uid Block UID + * @param {Object} attributes Block attributes to be merged + * @return {Object} Action object + */ +export function updateBlockAttributes( uid, attributes ) { + return { + type: 'UPDATE_BLOCK_ATTRIBUTES', + uid, + attributes, + }; +} + +/** + * Returns an action object used in signalling that the block with the + * specified UID has been updated. + * + * @param {String} uid Block UID + * @param {Object} updates Block attributes to be merged + * @return {Object} Action object + */ +export function updateBlock( uid, updates ) { + return { + type: 'UPDATE_BLOCK', + uid, + updates, + }; +} + +/** + * Returns an action object signalling that a blocks should be replaced with + * one or more replacement blocks. + * + * @param {(String|String[])} uids Block UID(s) to replace + * @param {(Object|Object[])} blocks Replacement block(s) + * @return {Object} Action object + */ +export function replaceBlocks( uids, blocks ) { + return { + type: 'REPLACE_BLOCKS', + uids: castArray( uids ), + blocks: castArray( blocks ), + }; +} + +/** + * Returns an action object signalling that a single block should be replaced + * with one or more replacement blocks. + * + * @param {(String|String[])} uid Block UID(s) to replace + * @param {(Object|Object[])} block Replacement block(s) + * @return {Object} Action object + */ +export function replaceBlock( uid, block ) { + return replaceBlocks( uid, block ); +} + +/** + * Returns an action object signalling that a block should be inserted at the + * specified position. + * + * @param {Object} block Block object + * @param {Number} position Index at which to insert + * @return {Object} Action object + */ +export function insertBlock( block, position ) { + return insertBlocks( [ block ], position ); +} + +/** + * Returns an action object signalling that blocks should be inserted at the + * specified position. + * + * @param {Object[]} blocks Block objects + * @param {Number} position Index at which to insert + * @return {Object} Action object + */ +export function insertBlocks( blocks, position ) { + return { + type: 'INSERT_BLOCKS', + blocks: castArray( blocks ), + position, + }; +} + +/** + * Returns an action object signalling that two blocks should be merged. + * + * @param {Object} blockA First block to merge + * @param {Object} blockB Second block to merge + * @return {Object} Action object + */ +export function mergeBlocks( blockA, blockB ) { + return { + type: 'MERGE_BLOCKS', + blocks: [ blockA, blockB ], + }; +} + +/** + * Returns an action object used in signalling that the blocks + * corresponding to the specified UID set are to be removed. + * + * @param {String[]} uids Block UIDs + * @return {Object} Action object + */ +export function removeBlocks( uids ) { + return { + type: 'REMOVE_BLOCKS', + uids, + }; +} + +/** + * Returns an action object used in signalling that the block with the + * specified UID is to be removed. + * + * @param {String} uid Block UID + * @return {Object} Action object + */ +export function removeBlock( uid ) { + return removeBlocks( [ uid ] ); +} + +/** + * Returns an action object used in signalling that undo history should + * restore last popped state. + * + * @return {Object} Action object + */ +export function redo() { + return { type: 'REDO' }; +} + +/** + * Returns an action object used in signalling that undo history should pop. + * + * @return {Object} Action object + */ +export function undo() { + return { type: 'UNDO' }; +} + +/** + * Selectors + */ + +/** + * 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.present.edits; +} + +/** + * 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.isDirty || isMetaBoxStateDirty( state ); +} + +/** + * Returns true if there are no unsaved values for the current edit session and if + * the currently edited post is new (and has never been saved before). + * + * @param {Object} state Global application state + * @return {Boolean} Whether new post and unsaved values exist + */ +export function isCleanNewPost( state ) { + return ! isEditedPostDirty( state ) && isCurrentPostNew( state ); +} + +/** + * Return true if the post being edited can be published + * + * @param {Object} state Global application state + * @return {Boolean} Whether the post can been published + */ +export function isEditedPostPublishable( state ) { + const post = getCurrentPost( state ); + return isEditedPostDirty( state ) || [ 'publish', 'private', 'future' ].indexOf( post.status ) === -1; +} + +/** + * 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 ) { + const currentPost = getCurrentPost( state ); + + return state.editor.present.edits[ attributeName ] === undefined ? + currentPost[ attributeName ] : + state.editor.present.edits[ attributeName ]; +} + +/** + * 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 visibility + */ +export function getEditedPostVisibility( state ) { + const status = getEditedPostAttribute( state, 'status' ); + const password = getEditedPostAttribute( state, 'password' ); + + if ( status === 'private' ) { + return 'private'; + } else if ( password ) { + return 'password'; + } + return 'public'; +} + +/** + * Return true if the post being edited is being scheduled. Preferring the + * unsaved status values. + * + * @param {Object} state Global application state + * @return {Boolean} Whether the post has been published + */ +export function isEditedPostBeingScheduled( state ) { + const date = getEditedPostAttribute( state, 'date' ); + // Adding 1 minute as an error threshold between the server and the client dates. + const now = moment().add( 1, 'minute' ); + + return moment( date ).isAfter( now ); +} + +/** + * Returns true if the post can be saved, or false otherwise. A post must + * contain a title, an excerpt, or non-empty content to be valid for save. + * + * @param {Object} state Global application state + * @return {Boolean} Whether the post can be saved + */ +export function isEditedPostSaveable( state ) { + return ( + !! getEditedPostTitle( state ) || + !! getEditedPostExcerpt( state ) || + !! getEditedPostContent( state ) + ); +} + +/** + * 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 ) { + const editedTitle = getPostEdits( state ).title; + if ( editedTitle !== undefined ) { + return editedTitle; + } + const currentPost = getCurrentPost( state ); + if ( currentPost.title && currentPost.title ) { + return currentPost.title; + } + return ''; +} + +/** + * 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 const getBlock = createSelector( + ( state, uid ) => { + const block = state.editor.present.blocksByUid[ uid ]; + if ( ! block ) { + return null; + } + + const type = getBlockType( block.name ); + if ( ! type || ! type.attributes ) { + return block; + } + + const metaAttributes = reduce( type.attributes, ( result, value, key ) => { + if ( value.source === 'meta' ) { + result[ key ] = getPostMeta( state, value.meta ); + } + + return result; + }, {} ); + + if ( ! Object.keys( metaAttributes ).length ) { + return block; + } + + return { + ...block, + attributes: { + ...block.attributes, + ...metaAttributes, + }, + }; + }, + ( state, uid ) => [ + get( state, [ 'editor', 'present', 'blocksByUid', uid ] ), + get( state, [ 'editor', 'present', 'edits', 'meta' ] ), + get( state, 'currentPost.meta' ), + ] +); + +/** + * 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.present.edits.excerpt === undefined ? + state.currentPost.excerpt : + state.editor.present.edits.excerpt; +} + +function getPostMeta( state, key ) { + return has( state, [ 'editor', 'edits', 'present', 'meta', key ] ) ? + get( state, [ 'editor', 'edits', 'present', 'meta', key ] ) : + get( state, [ 'currentPost', 'meta', key ] ); +} + +/** + * Returns all block objects for the current post being edited as an array in + * the order they appear in the post. + * Note: It's important to memoize this selector to avoid return a new instance on each call + * + * @param {Object} state Global application state + * @return {Object[]} Post blocks + */ +export const getBlocks = createSelector( + ( state ) => { + return state.editor.present.blockOrder.map( ( uid ) => getBlock( state, uid ) ); + }, + ( state ) => [ + state.editor.present.blockOrder, + state.editor.present.blocksByUid, + ] +); + +/** + * Returns the number of blocks currently present in the post. + * + * @param {Object} state Global application state + * @return {Number} Number of blocks in the post + */ +export function getBlockCount( state ) { + return getBlockUids( state ).length; +} + +/** + * 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.present.blockOrder; +} + +/** + * 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.present.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 {String} uid Block unique ID + * @return {Boolean} Whether block is first in post + */ +export function isFirstBlock( state, uid ) { + return first( state.editor.present.blockOrder ) === 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 {String} uid Block unique ID + * @return {Boolean} Whether block is last in post + */ +export function isLastBlock( state, uid ) { + return last( state.editor.present.blockOrder ) === uid; +} + +/** + * Returns the block object occurring before the one corresponding to the + * specified unique ID. + * + * @param {Object} state Global application state + * @param {String} uid Block unique ID + * @return {Object} Block occurring before specified unique ID + */ +export function getPreviousBlock( state, uid ) { + const order = getBlockIndex( state, uid ); + return state.editor.present.blocksByUid[ state.editor.present.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 {String} uid Block unique ID + * @return {Object} Block occurring after specified unique ID + */ +export function getNextBlock( state, uid ) { + const order = getBlockIndex( state, uid ); + return state.editor.present.blocksByUid[ state.editor.present.blockOrder[ order + 1 ] ] || null; +} + +/** + * Returns the content of the post being edited, preferring raw string edit + * before falling back to serialization of block state. + * + * @param {Object} state Global application state + * @return {String} Post content + */ +export const getEditedPostContent = createSelector( + ( state ) => { + const edits = getPostEdits( state ); + if ( 'content' in edits ) { + return edits.content; + } + + return serialize( getBlocks( state ) ); + }, + ( state ) => [ + state.editor.present.edits.content, + state.editor.present.blocksByUid, + state.editor.present.blockOrder, + ], +); + +/** + * 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.present.blockOrder; + + let name; + // If there is only one block in the content of the post grab its name + // so we can derive a suitable post format from it. + if ( blocks.length === 1 ) { + name = state.editor.present.blocksByUid[ blocks[ 0 ] ].name; + } + + // If there are two blocks in the content and the last one is a text blocks + // grab the name of the first one to also suggest a post format from it. + if ( blocks.length === 2 ) { + if ( state.editor.present.blocksByUid[ blocks[ 1 ] ].name === 'core/paragraph' ) { + name = state.editor.present.blocksByUid[ blocks[ 0 ] ].name; + } + } + + // We only convert to default post formats in core. + switch ( name ) { + case 'core/image': + return 'image'; + case 'core/quote': + case 'core/pullquote': + return 'quote'; + case 'core/gallery': + return 'gallery'; + case 'core/video': + case 'core-embed/youtube': + case 'core-embed/vimeo': + return 'video'; + case 'core/audio': + case 'core-embed/spotify': + case 'core-embed/soundcloud': + return 'audio'; + } + + return null; +} + +/** + * 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.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.future.length > 0; +} diff --git a/editor/state/effects.js b/editor/state/effects.js index e59804334d4d96..c85a9efb7d443e 100644 --- a/editor/state/effects.js +++ b/editor/state/effects.js @@ -20,13 +20,15 @@ import { resetBlocks, focusBlock, replaceBlocks, - createSuccessNotice, - createErrorNotice, - removeNotice, savePost, editPost, requestMetaBoxUpdates, } from './actions'; +import { + createSuccessNotice, + createErrorNotice, + removeNotice, +} from './notices'; import { getCurrentPost, getCurrentPostType, @@ -35,7 +37,7 @@ import { getPostEdits, isCurrentPostPublished, isEditedPostDirty, - isEditedPostNew, + isCurrentPostNew, isEditedPostSaveable, getMetaBoxes, } from './selectors'; @@ -235,7 +237,7 @@ export default { return; } - if ( ! isEditedPostNew( state ) && ! isEditedPostDirty( state ) ) { + if ( ! isCurrentPostNew( state ) && ! isEditedPostDirty( state ) ) { return; } @@ -249,7 +251,7 @@ export default { } // Change status from auto-draft to draft - if ( isEditedPostNew( state ) ) { + if ( isCurrentPostNew( state ) ) { dispatch( editPost( { status: 'draft' } ) ); } diff --git a/editor/state/hovered-block.js b/editor/state/hovered-block.js new file mode 100644 index 00000000000000..0e92dcc3b25541 --- /dev/null +++ b/editor/state/hovered-block.js @@ -0,0 +1,45 @@ +/** + * Reducer + */ + +/** + * Reducer returning hovered block state. + * + * @param {Object} state Current state + * @param {Object} action Dispatched action + * @return {Object} Updated state + */ +export default function( state = null, action ) { + switch ( action.type ) { + case 'TOGGLE_BLOCK_HOVERED': + return action.hovered ? action.uid : null; + case 'SELECT_BLOCK': + case 'START_TYPING': + case 'MULTI_SELECT': + return null; + case 'REPLACE_BLOCKS': + if ( ! action.blocks || ! action.blocks.length || action.uids.indexOf( state ) === -1 ) { + return state; + } + + return action.blocks[ 0 ].uid; + } + + return state; +} + +/** + * Selectors + */ + +/** + * 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 {String} uid Block unique ID + * @return {Boolean} Whether block is hovered + */ +export function isBlockHovered( state, uid ) { + return state.hoveredBlock === uid; +} diff --git a/editor/state/index.js b/editor/state/index.js index 888533fb1bd530..c82a636f1051d1 100644 --- a/editor/state/index.js +++ b/editor/state/index.js @@ -1,7 +1,8 @@ /** * External dependencies */ -import { applyMiddleware, createStore } from 'redux'; +import { applyMiddleware, createStore, combineReducers } from 'redux'; +import optimist from 'redux-optimist'; import refx from 'refx'; import multi from 'redux-multi'; import { flowRight } from 'lodash'; @@ -10,10 +11,38 @@ import { flowRight } from 'lodash'; * Internal dependencies */ import effects from './effects'; -import { mobileMiddleware } from './utils/mobile'; -import reducer from './reducer'; +import { mobileMiddleware } from '../utils/mobile'; import storePersist from './store-persist'; import { PREFERENCES_DEFAULTS } from './store-defaults'; +import editor from './editor'; +import currentPost from './current-post'; +import preferences from './preferences'; +import metaBoxes from './meta-boxes'; +import notices from './notices'; +import isTyping from './is-typing'; +import saving from './saving'; +import blockInsertionPoint from './block-insertion-point'; +import blockSelection from './block-selection'; +import reusableBlocks from './reusable-blocks'; +import panel from './panel'; +import hoveredBlock from './hovered-block'; +import blocksMode from './blocks-mode'; + +const reducer = optimist( combineReducers( { + editor, + currentPost, + isTyping, + blockSelection, + hoveredBlock, + blocksMode, + blockInsertionPoint, + preferences, + panel, + saving, + notices, + metaBoxes, + reusableBlocks, +} ) ); /** * Module constants diff --git a/editor/state/is-typing.js b/editor/state/is-typing.js new file mode 100644 index 00000000000000..189e6146fba12d --- /dev/null +++ b/editor/state/is-typing.js @@ -0,0 +1,62 @@ +/** + * Reducer + */ + +/** + * Reducer returning typing state. + * + * @param {Boolean} state Current state + * @param {Object} action Dispatched action + * @return {Boolean} Updated state + */ +export default function( state = false, action ) { + switch ( action.type ) { + case 'START_TYPING': + return true; + + case 'STOP_TYPING': + return false; + } + + return state; +} + +/** + * Action creators + */ + +/** + * Returns an action object used in signalling that the user has begun to type. + * + * @return {Object} Action object + */ +export function startTyping() { + return { + type: 'START_TYPING', + }; +} + +/** + * Returns an action object used in signalling that the user has stopped typing. + * + * @return {Object} Action object + */ +export function stopTyping() { + return { + type: 'STOP_TYPING', + }; +} + +/** + * Selectors + */ + +/** + * Returns true if the user is typing, or false otherwise. + * + * @param {Object} state Global application state + * @return {Boolean} Whether user is typing + */ +export function isTyping( state ) { + return state.isTyping; +} diff --git a/editor/state/meta-boxes.js b/editor/state/meta-boxes.js new file mode 100644 index 00000000000000..c63b4f9d198099 --- /dev/null +++ b/editor/state/meta-boxes.js @@ -0,0 +1,222 @@ +/** + * External dependencies + */ +import { reduce } from 'lodash'; +import createSelector from 'rememo'; + +/** + * Supported meta box locations. + * + * @type {Array} + */ +const LOCATIONS = [ + 'normal', + 'side', +]; + +/** + * Default reducer state. + * + * @type {Object} + */ +const DEFAULT_STATE = LOCATIONS.reduce( ( result, key ) => { + result[ key ] = { + isActive: false, + isDirty: false, + isUpdating: false, + }; + + return result; +}, {} ); + +/** + * Reducer + */ + +export default function( state = DEFAULT_STATE, action ) { + switch ( action.type ) { + case 'INITIALIZE_META_BOX_STATE': + return LOCATIONS.reduce( ( newState, location ) => { + newState[ location ] = { + ...state[ location ], + isLoaded: false, + isActive: action.metaBoxes[ location ], + }; + return newState; + }, { ...state } ); + case 'META_BOX_LOADED': + return { + ...state, + [ action.location ]: { + ...state[ action.location ], + isLoaded: true, + isUpdating: false, + isDirty: false, + }, + }; + case 'HANDLE_META_BOX_RELOAD': + return { + ...state, + [ action.location ]: { + ...state[ action.location ], + isUpdating: false, + isDirty: false, + }, + }; + case 'REQUEST_META_BOX_UPDATES': + return action.locations.reduce( ( newState, location ) => { + newState[ location ] = { + ...state[ location ], + isUpdating: true, + isDirty: false, + }; + return newState; + }, { ...state } ); + case 'META_BOX_STATE_CHANGED': + return { + ...state, + [ action.location ]: { + ...state[ action.location ], + isDirty: action.hasChanged, + }, + }; + default: + return state; + } +} + +/** + * Action creators + */ + +/** + * Returns an action object used to check the state of meta boxes at a location. + * + * This should only be fired once to initialize meta box state. If a meta box + * area is empty, this will set the store state to indicate that React should + * not render the meta box area. + * + * Example: initialMetaBoxState = { side: true, normal: false } + * This indicates that the sidebar has a meta box but the normal area does not. + * + * @param {Object} initialMetaBoxState Whether meta box locations are active. + * + * @return {Object} Action object + */ +export function initializeMetaBoxState( initialMetaBoxState ) { + return { + type: 'INITIALIZE_META_BOX_STATE', + metaBoxes: initialMetaBoxState, + }; +} + +/** + * Returns an action object used to signify that a meta box finished reloading. + * + * @param {String} location Location of meta box: 'normal', 'side'. + * + * @return {Object} Action object + */ +export function handleMetaBoxReload( location ) { + return { + type: 'HANDLE_META_BOX_RELOAD', + location, + }; +} + +/** + * Returns an action object used to signify that a meta box finished loading. + * + * @param {String} location Location of meta box: 'normal', 'side'. + * + * @return {Object} Action object + */ +export function metaBoxLoaded( location ) { + return { + type: 'META_BOX_LOADED', + location, + }; +} + +/** + * Returns an action object used to request meta box update. + * + * @param {Array} locations Locations of meta boxes: ['normal', 'side' ]. + * + * @return {Object} Action object + */ +export function requestMetaBoxUpdates( locations ) { + return { + type: 'REQUEST_META_BOX_UPDATES', + locations, + }; +} + +/** + * Returns an action object used to set meta box state changed. + * + * @param {String} location Location of meta box: 'normal', 'side'. + * @param {Boolean} hasChanged Whether the meta box has changed. + * + * @return {Object} Action object + */ +export function metaBoxStateChanged( location, hasChanged ) { + return { + type: 'META_BOX_STATE_CHANGED', + location, + hasChanged, + }; +} + +/** + * Selectors + */ + +/** + * Returns the state of legacy meta boxes. + * + * @param {Object} state Global application state + * @return {Object} State of meta boxes + */ +export function getMetaBoxes( state ) { + return state.metaBoxes; +} + +/** + * Returns the state of legacy meta boxes. + * + * @param {Object} state Global application state + * @param {String} location Location of the meta box. + * @return {Object} State of meta box at specified location. + */ +export function getMetaBox( state, location ) { + return getMetaBoxes( state )[ location ]; +} + +/** + * Returns a list of dirty meta box locations. + * + * @param {Object} state Global application state + * @return {Array} Array of locations for dirty meta boxes. + */ +export const getDirtyMetaBoxes = createSelector( + ( state ) => { + return reduce( getMetaBoxes( state ), ( result, metaBox, location ) => { + return metaBox.isDirty && metaBox.isActive ? + [ ...result, location ] : + result; + }, [] ); + }, + ( state ) => state.metaBoxes, +); + +/** + * Returns the dirty state of legacy meta boxes. + * + * Checks whether the entire meta box state is dirty. So if a sidebar is dirty, + * but a normal area is not dirty, this will overall return dirty. + * + * @param {Object} state Global application state + * @return {Boolean} Whether state is dirty. True if dirty, false if not. + */ +export const isMetaBoxStateDirty = ( state ) => getDirtyMetaBoxes( state ).length > 0; diff --git a/editor/state/notices.js b/editor/state/notices.js new file mode 100644 index 00000000000000..55fea0d1517ab9 --- /dev/null +++ b/editor/state/notices.js @@ -0,0 +1,97 @@ +/** + * External dependencies + */ +import uuid from 'uuid/v4'; +import { + partial, + findIndex, +} from 'lodash'; + +/** + * Reducer + */ + +export default function( state = [], action ) { + switch ( action.type ) { + case 'CREATE_NOTICE': + return [ ...state, action.notice ]; + + case 'REMOVE_NOTICE': + const { noticeId } = action; + const index = findIndex( state, { id: noticeId } ); + if ( index === -1 ) { + return state; + } + + return [ + ...state.slice( 0, index ), + ...state.slice( index + 1 ), + ]; + } + + return state; +} + +/** + * Action creators + */ + +/** + * Returns an action object used to create a notice + * + * @param {String} status The notice status + * @param {WPElement} content The notice content + * @param {?Object} options The notice options. Available options: + * `id` (string; default auto-generated) + * `isDismissible` (boolean; default `true`) + * + * @return {Object} Action object + */ +export function createNotice( status, content, options = {} ) { + const { + id = uuid(), + isDismissible = true, + } = options; + return { + type: 'CREATE_NOTICE', + notice: { + id, + status, + content, + isDismissible, + }, + }; +} + +export const createSuccessNotice = partial( createNotice, 'success' ); +export const createInfoNotice = partial( createNotice, 'info' ); +export const createErrorNotice = partial( createNotice, 'error' ); +export const createWarningNotice = partial( createNotice, 'warning' ); + +/** + * Returns an action object used to remove a notice + * + * @param {String} id The notice id + * + * @return {Object} Action object + */ +export function removeNotice( id ) { + return { + type: 'REMOVE_NOTICE', + noticeId: id, + }; +} + +/** + * Selectors + */ + +/** + * Returns the user notices array + * + * @param {Object} state Global application state + * @return {Array} List of notices + */ +export function getNotices( state ) { + return state.notices; +} diff --git a/editor/state/panel.js b/editor/state/panel.js new file mode 100644 index 00000000000000..3e2211b54807d9 --- /dev/null +++ b/editor/state/panel.js @@ -0,0 +1,51 @@ +/** + * Reducer + */ + +/** + * Reducer returning active panel, reflecting whether the user is editing + * document or block-specific settings. + * + * @param {Object} state Current state + * @param {Object} action Dispatched action + * @return {Object} Updated state + */ +export default function( state = 'document', action ) { + switch ( action.type ) { + case 'SET_ACTIVE_PANEL': + return action.panel; + } + + return state; +} + +/** + * Action creators + */ + +/** + * Returns an action object used in signalling that the user switched the active sidebar tab panel + * + * @param {String} panel The panel name + * @return {Object} Action object + */ +export function setActivePanel( panel ) { + return { + type: 'SET_ACTIVE_PANEL', + panel, + }; +} + +/** + * Selectors + */ + +/** + * Returns the current active panel for the sidebar. + * + * @param {Object} state Global application state + * @return {String} Active sidebar panel + */ +export function getActivePanel( state ) { + return state.panel; +} diff --git a/editor/state/preferences.js b/editor/state/preferences.js new file mode 100644 index 00000000000000..23bd509f9d57ea --- /dev/null +++ b/editor/state/preferences.js @@ -0,0 +1,258 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; +import { + difference, + get, + keys, + omit, + pick, + without, + compact, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { getBlockTypes, getBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { PREFERENCES_DEFAULTS } from './store-defaults'; + +/** + * The maximum number of recent blocks to track in state. + * + * @type {Number} + */ +const MAX_RECENT_BLOCKS = 8; + +/** + * The maximum number of frequently used blocks to return from selector. + * + * @type {Number} + */ +const MAX_FREQUENT_BLOCKS = 3; + +/** + * Reducer + */ + +/** + * Reducer returning the user preferences: + * + * @param {Object} state Current state + * @param {string} state.mode Current editor mode, either "visual" or "text". + * @param {Boolean} state.isSidebarOpened Whether the sidebar is opened or closed + * @param {Object} state.panels The state of the different sidebar panels + * @param {Object} action Dispatched action + * @return {string} Updated state + */ +export default function( state = PREFERENCES_DEFAULTS, action ) { + switch ( action.type ) { + case 'TOGGLE_SIDEBAR': + return { + ...state, + isSidebarOpened: ! state.isSidebarOpened, + }; + case 'TOGGLE_SIDEBAR_PANEL': + return { + ...state, + panels: { + ...state.panels, + [ action.panel ]: ! get( state, [ 'panels', action.panel ], false ), + }, + }; + case 'SWITCH_MODE': + return { + ...state, + mode: action.mode, + }; + case 'INSERT_BLOCKS': + // record the block usage and put the block in the recently used blocks + let blockUsage = state.blockUsage; + let recentlyUsedBlocks = [ ...state.recentlyUsedBlocks ]; + action.blocks.forEach( ( block ) => { + const uses = ( blockUsage[ block.name ] || 0 ) + 1; + blockUsage = omit( blockUsage, block.name ); + blockUsage[ block.name ] = uses; + recentlyUsedBlocks = [ block.name, ...without( recentlyUsedBlocks, block.name ) ].slice( 0, MAX_RECENT_BLOCKS ); + } ); + return { + ...state, + blockUsage, + recentlyUsedBlocks, + }; + case 'SETUP_EDITOR': + const isBlockDefined = name => getBlockType( name ) !== undefined; + const filterInvalidBlocksFromList = list => list.filter( isBlockDefined ); + const filterInvalidBlocksFromObject = obj => pick( obj, keys( obj ).filter( isBlockDefined ) ); + const commonBlocks = getBlockTypes() + .filter( ( blockType ) => 'common' === blockType.category ) + .map( ( blockType ) => blockType.name ); + + return { + ...state, + // recently used gets filled up to `MAX_RECENT_BLOCKS` with blocks from the common category + recentlyUsedBlocks: filterInvalidBlocksFromList( [ ...state.recentlyUsedBlocks ] ) + .concat( difference( commonBlocks, state.recentlyUsedBlocks ) ) + .slice( 0, MAX_RECENT_BLOCKS ), + blockUsage: filterInvalidBlocksFromObject( state.blockUsage ), + }; + case 'TOGGLE_FEATURE': + return { + ...state, + features: { + ...state.features, + [ action.feature ]: ! state.features[ action.feature ], + }, + }; + } + + return state; +} + +/** + * Action creators + */ + +/** + * Returns an action object used in signalling that the user toggled the sidebar + * + * @return {Object} Action object + */ +export function toggleSidebar() { + return { + type: 'TOGGLE_SIDEBAR', + }; +} + +/** + * Returns an action object used in signalling that the user toggled a sidebar panel + * + * @param {String} panel The panel name + * @return {Object} Action object + */ +export function toggleSidebarPanel( panel ) { + return { + type: 'TOGGLE_SIDEBAR_PANEL', + panel, + }; +} + +/** + * Returns an action object used to toggle a feature flag + * + * @param {String} feature Featurre name. + * + * @return {Object} Action object + */ +export function toggleFeature( feature ) { + return { + type: 'TOGGLE_FEATURE', + feature, + }; +} + +/** + * Selectors + */ + +/** + * Returns the current editing mode. + * + * @param {Object} state Global application state + * @return {String} Editing mode + */ +export function getEditorMode( state ) { + return getPreference( state, 'mode', 'visual' ); +} + +/** + * Returns the preferences (these preferences are persisted locally) + * + * @param {Object} state Global application state + * @return {Object} Preferences Object + */ +export function getPreferences( state ) { + return state.preferences; +} + +/** + * + * @param {Object} state Global application state + * @param {String} preferenceKey Preference Key + * @param {Mixed} defaultValue Default Value + * @return {Mixed} Preference Value + */ +export function getPreference( state, preferenceKey, defaultValue ) { + const value = getPreferences( state )[ preferenceKey ]; + return value === undefined ? defaultValue : value; +} + +/** + * Returns true if the editor sidebar is open, or false otherwise. + * + * @param {Object} state Global application state + * @return {Boolean} Whether sidebar is open + */ +export function isEditorSidebarOpened( state ) { + return getPreference( state, 'isSidebarOpened' ); +} + +/** + * Returns true if the editor sidebar panel is open, or false otherwise. + * + * @param {Object} state Global application state + * @param {STring} panel Sidebar panel name + * @return {Boolean} Whether sidebar is open + */ +export function isEditorSidebarPanelOpened( state, panel ) { + const panels = getPreference( state, 'panels' ); + return panels ? !! panels[ panel ] : false; +} + +/** + * Resolves the block usage stats into a list of the most frequently used blocks. + * Memoized so we're not generating block lists every time we render the list + * in the inserter. + * + * @param {Object} state Global application state + * @return {Array} List of block type settings + */ +export const getMostFrequentlyUsedBlocks = createSelector( + ( state ) => { + const { blockUsage } = state.preferences; + const orderedByUsage = keys( blockUsage ).sort( ( a, b ) => blockUsage[ b ] - blockUsage[ a ] ); + // add in paragraph and image blocks if they're not already in the usage data + return compact( + [ ...orderedByUsage, ...without( [ 'core/paragraph', 'core/image' ], ...orderedByUsage ) ] + .map( blockType => getBlockType( blockType ) ) + ).slice( 0, MAX_FREQUENT_BLOCKS ); + }, + ( state ) => state.preferences.blockUsage +); + +/** + * Resolves the list of recently used block names into a list of block type settings. + * + * @param {Object} state Global application state + * @return {Array} List of recently used blocks + */ +export function getRecentlyUsedBlocks( state ) { + // resolves the block names in the state to the block type settings + return compact( state.preferences.recentlyUsedBlocks.map( blockType => getBlockType( blockType ) ) ); +} + +/** + * Returns whether the given feature is enabled or not + * + * @param {Object} state Global application state + * @param {String} feature Feature slug + * @return {Booleean} Is active + */ +export function isFeatureActive( state, feature ) { + return !! state.preferences.features[ feature ]; +} diff --git a/editor/state/reducer.js b/editor/state/reducer.js deleted file mode 100644 index 6ea20a035fcffd..00000000000000 --- a/editor/state/reducer.js +++ /dev/null @@ -1,734 +0,0 @@ -/** - * External dependencies - */ -import optimist from 'redux-optimist'; -import { combineReducers } from 'redux'; -import { - flow, - partialRight, - difference, - get, - reduce, - keyBy, - keys, - first, - last, - omit, - pick, - without, - mapValues, - findIndex, -} from 'lodash'; - -/** - * WordPress dependencies - */ -import { getBlockTypes, getBlockType } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import withHistory from '../utils/with-history'; -import withChangeDetection from '../utils/with-change-detection'; -import { PREFERENCES_DEFAULTS } from './store-defaults'; - -/*** - * Module constants - */ -const MAX_RECENT_BLOCKS = 8; - -/** - * Returns a post attribute value, flattening nested rendered content using its - * raw value in place of its original object form. - * - * @param {*} value Original value - * @return {*} Raw value - */ -export function getPostRawValue( value ) { - if ( value && 'object' === typeof value && 'raw' in value ) { - return value.raw; - } - - return value; -} - -/** - * Undoable reducer returning the editor post state, including blocks parsed - * from current HTML markup. - * - * Handles the following state keys: - * - edits: an object describing changes to be made to the current post, in - * the format accepted by the WP REST API - * - blocksByUid: post content blocks keyed by UID - * - blockOrder: list of block UIDs in order - * - * @param {Object} state Current state - * @param {Object} action Dispatched action - * @return {Object} Updated state - */ -export const editor = flow( [ - combineReducers, - - // Track undo history, starting at editor initialization. - partialRight( withHistory, { resetTypes: [ 'SETUP_EDITOR' ] } ), - - // Track whether changes exist, starting at editor initialization and - // resetting at each post save. - partialRight( withChangeDetection, { resetTypes: [ 'SETUP_EDITOR', 'RESET_POST' ] } ), -] )( { - edits( state = {}, action ) { - switch ( action.type ) { - case 'EDIT_POST': - case 'SETUP_NEW_POST': - return reduce( action.edits, ( result, value, key ) => { - // Only assign into result if not already same value - if ( value !== state[ key ] ) { - // Avoid mutating original state by creating shallow - // clone. Should only occur once per reduce. - if ( result === state ) { - result = { ...state }; - } - - result[ key ] = value; - } - - return result; - }, state ); - - case 'RESET_BLOCKS': - if ( 'content' in state ) { - return omit( state, 'content' ); - } - - return state; - - case 'RESET_POST': - return reduce( state, ( result, value, key ) => { - if ( value !== getPostRawValue( action.post[ key ] ) ) { - return result; - } - - if ( state === result ) { - result = { ...state }; - } - - delete result[ key ]; - return result; - }, state ); - } - - return state; - }, - - blocksByUid( state = {}, action ) { - switch ( action.type ) { - case 'RESET_BLOCKS': - return keyBy( action.blocks, 'uid' ); - - case 'UPDATE_BLOCK_ATTRIBUTES': - // Ignore updates if block isn't known - if ( ! state[ action.uid ] ) { - return state; - } - - // Consider as updates only changed values - const nextAttributes = reduce( action.attributes, ( result, value, key ) => { - if ( value !== result[ key ] ) { - // Avoid mutating original block by creating shallow clone - if ( result === state[ action.uid ].attributes ) { - result = { ...result }; - } - - result[ key ] = value; - } - - return result; - }, state[ action.uid ].attributes ); - - // Skip update if nothing has been changed. The reference will - // match the original block if `reduce` had no changed values. - if ( nextAttributes === state[ action.uid ].attributes ) { - return state; - } - - // Otherwise merge attributes into state - return { - ...state, - [ action.uid ]: { - ...state[ action.uid ], - attributes: nextAttributes, - }, - }; - - case 'UPDATE_BLOCK': - // Ignore updates if block isn't known - if ( ! state[ action.uid ] ) { - return state; - } - - return { - ...state, - [ action.uid ]: { - ...state[ action.uid ], - ...action.updates, - }, - }; - - case 'INSERT_BLOCKS': - return { - ...state, - ...keyBy( action.blocks, 'uid' ), - }; - - case 'REPLACE_BLOCKS': - if ( ! action.blocks ) { - return state; - } - return action.blocks.reduce( ( memo, block ) => { - return { - ...memo, - [ block.uid ]: block, - }; - }, omit( state, action.uids ) ); - - case 'REMOVE_BLOCKS': - return omit( state, action.uids ); - } - - return state; - }, - - blockOrder( state = [], action ) { - switch ( action.type ) { - case 'RESET_BLOCKS': - return action.blocks.map( ( { uid } ) => uid ); - - case 'INSERT_BLOCKS': { - const position = action.position !== undefined ? action.position : state.length; - return [ - ...state.slice( 0, position ), - ...action.blocks.map( block => block.uid ), - ...state.slice( position ), - ]; - } - - case 'MOVE_BLOCKS_UP': { - const firstUid = first( action.uids ); - const lastUid = last( action.uids ); - - if ( ! state.length || firstUid === first( state ) ) { - return state; - } - - const firstIndex = state.indexOf( firstUid ); - const lastIndex = state.indexOf( lastUid ); - const swappedUid = state[ firstIndex - 1 ]; - - return [ - ...state.slice( 0, firstIndex - 1 ), - ...action.uids, - swappedUid, - ...state.slice( lastIndex + 1 ), - ]; - } - - case 'MOVE_BLOCKS_DOWN': { - const firstUid = first( action.uids ); - const lastUid = last( action.uids ); - - if ( ! state.length || lastUid === last( state ) ) { - return state; - } - - const firstIndex = state.indexOf( firstUid ); - const lastIndex = state.indexOf( lastUid ); - const swappedUid = state[ lastIndex + 1 ]; - - return [ - ...state.slice( 0, firstIndex ), - swappedUid, - ...action.uids, - ...state.slice( lastIndex + 2 ), - ]; - } - - case 'REPLACE_BLOCKS': - if ( ! action.blocks ) { - return state; - } - - return state.reduce( ( memo, uid ) => { - if ( uid === action.uids[ 0 ] ) { - return memo.concat( action.blocks.map( ( block ) => block.uid ) ); - } - if ( action.uids.indexOf( uid ) === -1 ) { - memo.push( uid ); - } - return memo; - }, [] ); - - case 'REMOVE_BLOCKS': - return without( state, ...action.uids ); - } - - return state; - }, -} ); - -/** - * Reducer returning the last-known state of the current post, in the format - * returned by the WP REST API. - * - * @param {Object} state Current state - * @param {Object} action Dispatched action - * @return {Object} Updated state - */ -export function currentPost( state = {}, action ) { - switch ( action.type ) { - case 'RESET_POST': - case 'UPDATE_POST': - let post; - if ( action.post ) { - post = action.post; - } else if ( action.edits ) { - post = { - ...state, - ...action.edits, - }; - } else { - return state; - } - - return mapValues( post, getPostRawValue ); - } - - return state; -} - -/** - * Reducer returning typing state. - * - * @param {Boolean} state Current state - * @param {Object} action Dispatched action - * @return {Boolean} Updated state - */ -export function isTyping( state = false, action ) { - switch ( action.type ) { - case 'START_TYPING': - return true; - - case 'STOP_TYPING': - return false; - } - - return state; -} - -/** - * Reducer returning the block selection's state. - * - * @param {Object} state Current state - * @param {Object} action Dispatched action - * @return {Object} Updated state - */ -export function blockSelection( state = { start: null, end: null, focus: null, isMultiSelecting: false }, action ) { - switch ( action.type ) { - case 'CLEAR_SELECTED_BLOCK': - return { - start: null, - end: null, - focus: null, - isMultiSelecting: false, - }; - case 'START_MULTI_SELECT': - return { - ...state, - isMultiSelecting: true, - }; - case 'STOP_MULTI_SELECT': - return { - ...state, - isMultiSelecting: false, - focus: state.start === state.end ? state.focus : null, - }; - case 'MULTI_SELECT': - return { - ...state, - start: action.start, - end: action.end, - focus: state.isMultiSelecting ? state.focus : null, - }; - case 'SELECT_BLOCK': - if ( action.uid === state.start && action.uid === state.end ) { - return state; - } - return { - ...state, - start: action.uid, - end: action.uid, - focus: action.focus || {}, - }; - case 'UPDATE_FOCUS': - return { - ...state, - start: action.uid, - end: action.uid, - focus: action.config || {}, - }; - case 'INSERT_BLOCKS': - return { - start: action.blocks[ 0 ].uid, - end: action.blocks[ 0 ].uid, - focus: {}, - isMultiSelecting: false, - }; - case 'REPLACE_BLOCKS': - if ( ! action.blocks || ! action.blocks.length || action.uids.indexOf( state.start ) === -1 ) { - return state; - } - return { - start: action.blocks[ 0 ].uid, - end: action.blocks[ 0 ].uid, - focus: {}, - isMultiSelecting: false, - }; - } - - return state; -} - -/** - * Reducer returning hovered block state. - * - * @param {Object} state Current state - * @param {Object} action Dispatched action - * @return {Object} Updated state - */ -export function hoveredBlock( state = null, action ) { - switch ( action.type ) { - case 'TOGGLE_BLOCK_HOVERED': - return action.hovered ? action.uid : null; - case 'SELECT_BLOCK': - case 'START_TYPING': - case 'MULTI_SELECT': - return null; - case 'REPLACE_BLOCKS': - if ( ! action.blocks || ! action.blocks.length || action.uids.indexOf( state ) === -1 ) { - return state; - } - - return action.blocks[ 0 ].uid; - } - - return state; -} - -export function blocksMode( state = {}, action ) { - if ( action.type === 'TOGGLE_BLOCK_MODE' ) { - const { uid } = action; - return { - ...state, - [ uid ]: state[ uid ] && state[ uid ] === 'html' ? 'visual' : 'html', - }; - } - - return state; -} - -/** - * Reducer returning the block insertion point - * - * @param {Object} state Current state - * @param {Object} action Dispatched action - * @return {Object} Updated state - */ -export function blockInsertionPoint( state = {}, action ) { - switch ( action.type ) { - case 'SET_BLOCK_INSERTION_POINT': - const { position } = action; - return { ...state, position }; - - case 'CLEAR_BLOCK_INSERTION_POINT': - return { ...state, position: null }; - - case 'SHOW_INSERTION_POINT': - return { ...state, visible: true }; - - case 'HIDE_INSERTION_POINT': - return { ...state, visible: false }; - } - - return state; -} - -/** - * Reducer returning the user preferences: - * - * @param {Object} state Current state - * @param {string} state.mode Current editor mode, either "visual" or "text". - * @param {Boolean} state.isSidebarOpened Whether the sidebar is opened or closed - * @param {Object} state.panels The state of the different sidebar panels - * @param {Object} action Dispatched action - * @return {string} Updated state - */ -export function preferences( state = PREFERENCES_DEFAULTS, action ) { - switch ( action.type ) { - case 'TOGGLE_SIDEBAR': - return { - ...state, - isSidebarOpened: ! state.isSidebarOpened, - }; - case 'TOGGLE_SIDEBAR_PANEL': - return { - ...state, - panels: { - ...state.panels, - [ action.panel ]: ! get( state, [ 'panels', action.panel ], false ), - }, - }; - case 'SWITCH_MODE': - return { - ...state, - mode: action.mode, - }; - case 'INSERT_BLOCKS': - // record the block usage and put the block in the recently used blocks - let blockUsage = state.blockUsage; - let recentlyUsedBlocks = [ ...state.recentlyUsedBlocks ]; - action.blocks.forEach( ( block ) => { - const uses = ( blockUsage[ block.name ] || 0 ) + 1; - blockUsage = omit( blockUsage, block.name ); - blockUsage[ block.name ] = uses; - recentlyUsedBlocks = [ block.name, ...without( recentlyUsedBlocks, block.name ) ].slice( 0, MAX_RECENT_BLOCKS ); - } ); - return { - ...state, - blockUsage, - recentlyUsedBlocks, - }; - case 'SETUP_EDITOR': - const isBlockDefined = name => getBlockType( name ) !== undefined; - const filterInvalidBlocksFromList = list => list.filter( isBlockDefined ); - const filterInvalidBlocksFromObject = obj => pick( obj, keys( obj ).filter( isBlockDefined ) ); - const commonBlocks = getBlockTypes() - .filter( ( blockType ) => 'common' === blockType.category ) - .map( ( blockType ) => blockType.name ); - - return { - ...state, - // recently used gets filled up to `MAX_RECENT_BLOCKS` with blocks from the common category - recentlyUsedBlocks: filterInvalidBlocksFromList( [ ...state.recentlyUsedBlocks ] ) - .concat( difference( commonBlocks, state.recentlyUsedBlocks ) ) - .slice( 0, MAX_RECENT_BLOCKS ), - blockUsage: filterInvalidBlocksFromObject( state.blockUsage ), - }; - case 'TOGGLE_FEATURE': - return { - ...state, - features: { - ...state.features, - [ action.feature ]: ! state.features[ action.feature ], - }, - }; - } - - return state; -} - -export function panel( state = 'document', action ) { - switch ( action.type ) { - case 'SET_ACTIVE_PANEL': - return action.panel; - } - - return state; -} - -/** - * Reducer returning current network request state (whether a request to the WP - * REST API is in progress, successful, or failed). - * - * @param {Object} state Current state - * @param {Object} action Dispatched action - * @return {Object} Updated state - */ -export function saving( state = {}, action ) { - switch ( action.type ) { - case 'REQUEST_POST_UPDATE': - return { - requesting: true, - successful: false, - error: null, - }; - - case 'REQUEST_POST_UPDATE_SUCCESS': - return { - requesting: false, - successful: true, - error: null, - }; - - case 'REQUEST_POST_UPDATE_FAILURE': - return { - requesting: false, - successful: false, - error: action.error, - }; - } - - return state; -} - -export function notices( state = [], action ) { - switch ( action.type ) { - case 'CREATE_NOTICE': - return [ ...state, action.notice ]; - - case 'REMOVE_NOTICE': - const { noticeId } = action; - const index = findIndex( state, { id: noticeId } ); - if ( index === -1 ) { - return state; - } - - return [ - ...state.slice( 0, index ), - ...state.slice( index + 1 ), - ]; - } - - return state; -} - -const locations = [ - 'normal', - 'side', -]; - -const defaultMetaBoxState = locations.reduce( ( result, key ) => { - result[ key ] = { - isActive: false, - isDirty: false, - isUpdating: false, - }; - - return result; -}, {} ); - -export function metaBoxes( state = defaultMetaBoxState, action ) { - switch ( action.type ) { - case 'INITIALIZE_META_BOX_STATE': - return locations.reduce( ( newState, location ) => { - newState[ location ] = { - ...state[ location ], - isLoaded: false, - isActive: action.metaBoxes[ location ], - }; - return newState; - }, { ...state } ); - case 'META_BOX_LOADED': - return { - ...state, - [ action.location ]: { - ...state[ action.location ], - isLoaded: true, - isUpdating: false, - isDirty: false, - }, - }; - case 'HANDLE_META_BOX_RELOAD': - return { - ...state, - [ action.location ]: { - ...state[ action.location ], - isUpdating: false, - isDirty: false, - }, - }; - case 'REQUEST_META_BOX_UPDATES': - return action.locations.reduce( ( newState, location ) => { - newState[ location ] = { - ...state[ location ], - isUpdating: true, - isDirty: false, - }; - return newState; - }, { ...state } ); - case 'META_BOX_STATE_CHANGED': - return { - ...state, - [ action.location ]: { - ...state[ action.location ], - isDirty: action.hasChanged, - }, - }; - default: - return state; - } -} - -export const reusableBlocks = combineReducers( { - data( state = {}, action ) { - switch ( action.type ) { - case 'FETCH_REUSABLE_BLOCKS_SUCCESS': { - return reduce( action.reusableBlocks, ( newState, reusableBlock ) => ( { - ...newState, - [ reusableBlock.id ]: reusableBlock, - } ), state ); - } - - case 'UPDATE_REUSABLE_BLOCK': { - const { id, reusableBlock } = action; - const existingReusableBlock = state[ id ]; - - return { - ...state, - [ id ]: { - ...existingReusableBlock, - ...reusableBlock, - attributes: { - ...( existingReusableBlock && existingReusableBlock.attributes ), - ...reusableBlock.attributes, - }, - }, - }; - } - } - - return state; - }, - - isSaving( state = {}, action ) { - switch ( action.type ) { - case 'SAVE_REUSABLE_BLOCK': - return { - ...state, - [ action.id ]: true, - }; - - case 'SAVE_REUSABLE_BLOCK_SUCCESS': - case 'SAVE_REUSABLE_BLOCK_FAILURE': { - const { id } = action; - return omit( state, id ); - } - } - - return state; - }, -} ); - -export default optimist( combineReducers( { - editor, - currentPost, - isTyping, - blockSelection, - hoveredBlock, - blocksMode, - blockInsertionPoint, - preferences, - panel, - saving, - notices, - metaBoxes, - reusableBlocks, -} ) ); diff --git a/editor/state/reusable-blocks.js b/editor/state/reusable-blocks.js new file mode 100644 index 00000000000000..a6c975d3c9bde3 --- /dev/null +++ b/editor/state/reusable-blocks.js @@ -0,0 +1,173 @@ +/** + * External dependencies + */ +import { combineReducers } from 'redux'; +import { + reduce, + omit, +} from 'lodash'; + +/** + * Reducer + */ + +export default combineReducers( { + data( state = {}, action ) { + switch ( action.type ) { + case 'FETCH_REUSABLE_BLOCKS_SUCCESS': { + return reduce( action.reusableBlocks, ( newState, reusableBlock ) => ( { + ...newState, + [ reusableBlock.id ]: reusableBlock, + } ), state ); + } + + case 'UPDATE_REUSABLE_BLOCK': { + const { id, reusableBlock } = action; + const existingReusableBlock = state[ id ]; + + return { + ...state, + [ id ]: { + ...existingReusableBlock, + ...reusableBlock, + attributes: { + ...( existingReusableBlock && existingReusableBlock.attributes ), + ...reusableBlock.attributes, + }, + }, + }; + } + } + + return state; + }, + + isSaving( state = {}, action ) { + switch ( action.type ) { + case 'SAVE_REUSABLE_BLOCK': + return { + ...state, + [ action.id ]: true, + }; + + case 'SAVE_REUSABLE_BLOCK_SUCCESS': + case 'SAVE_REUSABLE_BLOCK_FAILURE': { + const { id } = action; + return omit( state, id ); + } + } + + return state; + }, +} ); + +/** + * Action creators + */ + +/** + * Returns an action object used to fetch a single reusable block or all + * reusable blocks from the REST API into the store. + * + * @param {?string} id If given, only a single reusable block with this ID will be fetched + * @return {Object} Action object + */ +export function fetchReusableBlocks( id ) { + return { + type: 'FETCH_REUSABLE_BLOCKS', + id, + }; +} + +/** + * Returns an action object used to insert or update a reusable block into the store. + * + * @param {Object} id The ID of the reusable block to update + * @param {Object} reusableBlock The new reusable block object. Any omitted keys are not changed + * @return {Object} Action object + */ +export function updateReusableBlock( id, reusableBlock ) { + return { + type: 'UPDATE_REUSABLE_BLOCK', + id, + reusableBlock, + }; +} + +/** + * Returns an action object used to save a reusable block that's in the store + * to the REST API. + * + * @param {Object} id The ID of the reusable block to save + * @return {Object} Action object + */ +export function saveReusableBlock( id ) { + return { + type: 'SAVE_REUSABLE_BLOCK', + id, + }; +} + +/** + * Returns an action object used to convert a reusable block into a static + * block. + * + * @param {Object} uid The ID of the block to attach + * @return {Object} Action object + */ +export function convertBlockToStatic( uid ) { + return { + type: 'CONVERT_BLOCK_TO_STATIC', + uid, + }; +} + +/** + * Returns an action object used to convert a static block into a reusable + * block. + * + * @param {Object} uid The ID of the block to detach + * @return {Object} Action object + */ +export function convertBlockToReusable( uid ) { + return { + type: 'CONVERT_BLOCK_TO_REUSABLE', + uid, + }; +} + +/** + * Selectors + */ + +/** + * Returns the reusable block with the given ID. + * + * @param {Object} state Global application state + * @param {String} ref The reusable block's ID + * @return {Object} The reusable block, or null if none exists + */ +export function getReusableBlock( state, ref ) { + return state.reusableBlocks.data[ ref ] || null; +} + +/** + * Returns whether or not the reusable block with the given ID is being saved. + * + * @param {*} state Global application state + * @param {*} ref The reusable block's ID + * @return {Boolean} Whether or not the reusable block is being saved + */ +export function isSavingReusableBlock( state, ref ) { + return state.reusableBlocks.isSaving[ ref ] || false; +} + +/** + * Returns an array of all reusable blocks. + * + * @param {Object} state Global application state + * @return {Array} An array of all reusable blocks. + */ +export function getReusableBlocks( state ) { + return Object.values( state.reusableBlocks.data ); +} diff --git a/editor/state/saving.js b/editor/state/saving.js new file mode 100644 index 00000000000000..b1f8c63a832510 --- /dev/null +++ b/editor/state/saving.js @@ -0,0 +1,100 @@ +/** + * Reducer + */ + +/** + * Reducer returning current network request state (whether a request to the WP + * REST API is in progress, successful, or failed). + * + * @param {Object} state Current state + * @param {Object} action Dispatched action + * @return {Object} Updated state + */ +export default function( state = {}, action ) { + switch ( action.type ) { + case 'REQUEST_POST_UPDATE': + return { + requesting: true, + successful: false, + error: null, + }; + + case 'REQUEST_POST_UPDATE_SUCCESS': + return { + requesting: false, + successful: true, + error: null, + }; + + case 'REQUEST_POST_UPDATE_FAILURE': + return { + requesting: false, + successful: false, + error: action.error, + }; + } + + return state; +} + +/** + * Action creators + */ + +/** + * Returns an action object used in signalling that the post should save. + * + * @return {Object} Action object + */ +export function savePost() { + return { + type: 'REQUEST_POST_UPDATE', + }; +} + +/** + * Returns an action object used in signalling that the post should autosave. + * + * @return {Object} Action object + */ +export function autosave() { + return { + type: 'AUTOSAVE', + }; +} + +/** + * Selectors + */ + +/** + * 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; +} diff --git a/editor/state/selectors.js b/editor/state/selectors.js index a3e231995f0626..a04acdaa208d20 100644 --- a/editor/state/selectors.js +++ b/editor/state/selectors.js @@ -1,1074 +1,95 @@ -/** - * External dependencies - */ -import moment from 'moment'; -import { - first, - get, - has, - last, - reduce, - keys, - without, - compact, -} from 'lodash'; -import createSelector from 'rememo'; - -/** - * WordPress dependencies - */ -import { serialize, getBlockType } from '@wordpress/blocks'; -import { __ } from '@wordpress/i18n'; -import { addQueryArgs } from '@wordpress/url'; - -/*** - * Module constants - */ -const MAX_FREQUENT_BLOCKS = 3; - -/** - * Returns the current editing mode. - * - * @param {Object} state Global application state - * @return {String} Editing mode - */ -export function getEditorMode( state ) { - return getPreference( state, 'mode', 'visual' ); -} - -/** - * Returns the state of legacy meta boxes. - * - * @param {Object} state Global application state - * @return {Object} State of meta boxes - */ -export function getMetaBoxes( state ) { - return state.metaBoxes; -} - -/** - * Returns the state of legacy meta boxes. - * - * @param {Object} state Global application state - * @param {String} location Location of the meta box. - * @return {Object} State of meta box at specified location. - */ -export function getMetaBox( state, location ) { - return getMetaBoxes( state )[ location ]; -} - -/** - * Returns a list of dirty meta box locations. - * - * @param {Object} state Global application state - * @return {Array} Array of locations for dirty meta boxes. - */ -export const getDirtyMetaBoxes = createSelector( - ( state ) => { - return reduce( getMetaBoxes( state ), ( result, metaBox, location ) => { - return metaBox.isDirty && metaBox.isActive ? - [ ...result, location ] : - result; - }, [] ); - }, - ( state ) => state.metaBoxes, -); - -/** - * Returns the dirty state of legacy meta boxes. - * - * Checks whether the entire meta box state is dirty. So if a sidebar is dirty, - * but a normal area is not dirty, this will overall return dirty. - * - * @param {Object} state Global application state - * @return {Boolean} Whether state is dirty. True if dirty, false if not. - */ -export const isMetaBoxStateDirty = ( state ) => getDirtyMetaBoxes( state ).length > 0; - -/** - * Returns the current active panel for the sidebar. - * - * @param {Object} state Global application state - * @return {String} Active sidebar panel - */ -export function getActivePanel( state ) { - return state.panel; -} - -/** - * Returns the preferences (these preferences are persisted locally) - * - * @param {Object} state Global application state - * @return {Object} Preferences Object - */ -export function getPreferences( state ) { - return state.preferences; -} - -/** - * - * @param {Object} state Global application state - * @param {String} preferenceKey Preference Key - * @param {Mixed} defaultValue Default Value - * @return {Mixed} Preference Value - */ -export function getPreference( state, preferenceKey, defaultValue ) { - const preferences = getPreferences( state ); - const value = preferences[ preferenceKey ]; - return value === undefined ? defaultValue : value; -} - -/** - * Returns true if the editor sidebar is open, or false otherwise. - * - * @param {Object} state Global application state - * @return {Boolean} Whether sidebar is open - */ -export function isEditorSidebarOpened( state ) { - return getPreference( state, 'isSidebarOpened' ); -} - -/** - * Returns true if the editor sidebar panel is open, or false otherwise. - * - * @param {Object} state Global application state - * @param {STring} panel Sidebar panel name - * @return {Boolean} Whether sidebar is open - */ -export function isEditorSidebarPanelOpened( state, panel ) { - const panels = getPreference( state, 'panels' ); - return panels ? !! panels[ panel ] : false; -} - -/** - * 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.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.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 getCurrentPost( state ).status === 'auto-draft'; -} - -/** - * 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.isDirty || isMetaBoxStateDirty( state ); -} - -/** - * Returns true if there are no unsaved values for the current edit session and if - * the currently edited post is new (and has never been saved before). - * - * @param {Object} state Global application state - * @return {Boolean} Whether new post and unsaved values exist - */ -export function isCleanNewPost( state ) { - return ! isEditedPostDirty( state ) && isEditedPostNew( state ); -} - -/** - * 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 the post type of the post currently being edited - * - * @param {Object} state Global application state - * @return {String} Post type - */ -export function getCurrentPostType( state ) { - return state.currentPost.type; -} - -/** - * Returns the ID of the post currently being edited, or null if the post has - * not yet been saved. - * - * @param {Object} state Global application state - * @return {?Number} ID of current post - */ -export function getCurrentPostId( state ) { - return getCurrentPost( state ).id || null; -} - -/** - * Returns the number of revisions of the post currently being edited. - * - * @param {Object} state Global application state - * @return {Number} Number of revisions - */ -export function getCurrentPostRevisionsCount( state ) { - return get( getCurrentPost( state ), 'revisions.count', 0 ); -} - -/** - * Returns the last revision ID of the post currently being edited, - * or null if the post has no revisions. - * - * @param {Object} state Global application state - * @return {?Number} ID of the last revision - */ -export function getCurrentPostLastRevisionId( state ) { - return get( getCurrentPost( state ), 'revisions.last_id', null ); -} - -/** - * 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.present.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.present.edits[ attributeName ] === undefined ? - state.currentPost[ attributeName ] : - state.editor.present.edits[ attributeName ]; -} - -/** - * 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 visibility - */ -export function getEditedPostVisibility( state ) { - const status = getEditedPostAttribute( state, 'status' ); - const password = getEditedPostAttribute( state, 'password' ); - - if ( status === 'private' ) { - return 'private'; - } else if ( password ) { - return 'password'; - } - return 'public'; -} - -/** - * Return true if the current post has already been published. - * - * @param {Object} state Global application state - * @return {Boolean} Whether the post has been published - */ -export function isCurrentPostPublished( state ) { - const post = getCurrentPost( state ); - - return [ 'publish', 'private' ].indexOf( post.status ) !== -1 || - ( post.status === 'future' && moment( post.date ).isBefore( moment() ) ); -} - -/** - * Return true if the post being edited can be published - * - * @param {Object} state Global application state - * @return {Boolean} Whether the post can been published - */ -export function isEditedPostPublishable( state ) { - const post = getCurrentPost( state ); - return isEditedPostDirty( state ) || [ 'publish', 'private', 'future' ].indexOf( post.status ) === -1; -} - -/** - * Returns true if the post can be saved, or false otherwise. A post must - * contain a title, an excerpt, or non-empty content to be valid for save. - * - * @param {Object} state Global application state - * @return {Boolean} Whether the post can be saved - */ -export function isEditedPostSaveable( state ) { - return ( - !! getEditedPostTitle( state ) || - !! getEditedPostExcerpt( state ) || - !! getEditedPostContent( state ) - ); -} - -/** - * Return true if the post being edited is being scheduled. Preferring the - * unsaved status values. - * - * @param {Object} state Global application state - * @return {Boolean} Whether the post has been published - */ -export function isEditedPostBeingScheduled( state ) { - const date = getEditedPostAttribute( state, 'date' ); - // Adding 1 minute as an error threshold between the server and the client dates. - const now = moment().add( 1, 'minute' ); - - return moment( date ).isAfter( now ); -} - -/** - * 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 ) { - const editedTitle = getPostEdits( state ).title; - if ( editedTitle !== undefined ) { - return editedTitle; - } - const currentPost = getCurrentPost( state ); - if ( currentPost.title && currentPost.title ) { - return currentPost.title; - } - return ''; -} - -/** - * Gets the document title to be used. - * - * @param {Object} state Global application state - * @return {string} Document title - */ -export function getDocumentTitle( state ) { - let title = getEditedPostTitle( state ); - - if ( ! title.trim() ) { - title = isCleanNewPost( state ) ? __( 'New post' ) : __( '(Untitled)' ); - } - return 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.present.edits.excerpt === undefined ? - state.currentPost.excerpt : - state.editor.present.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 ) { - return null; - } - - 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 const getBlock = createSelector( - ( state, uid ) => { - const block = state.editor.present.blocksByUid[ uid ]; - if ( ! block ) { - return null; - } - - const type = getBlockType( block.name ); - if ( ! type || ! type.attributes ) { - return block; - } - - const metaAttributes = reduce( type.attributes, ( result, value, key ) => { - if ( value.source === 'meta' ) { - result[ key ] = getPostMeta( state, value.meta ); - } - - return result; - }, {} ); - - if ( ! Object.keys( metaAttributes ).length ) { - return block; - } - - return { - ...block, - attributes: { - ...block.attributes, - ...metaAttributes, - }, - }; - }, - ( state, uid ) => [ - get( state, [ 'editor', 'present', 'blocksByUid', uid ] ), - get( state, [ 'editor', 'present', 'edits', 'meta' ] ), - get( state, 'currentPost.meta' ), - ] -); - -function getPostMeta( state, key ) { - return has( state, [ 'editor', 'edits', 'present', 'meta', key ] ) ? - get( state, [ 'editor', 'edits', 'present', 'meta', key ] ) : - get( state, [ 'currentPost', 'meta', key ] ); -} - -/** - * Returns all block objects for the current post being edited as an array in - * the order they appear in the post. - * Note: It's important to memoize this selector to avoid return a new instance on each call - * - * @param {Object} state Global application state - * @return {Object[]} Post blocks - */ -export const getBlocks = createSelector( - ( state ) => { - return state.editor.present.blockOrder.map( ( uid ) => getBlock( state, uid ) ); - }, - ( state ) => [ - state.editor.present.blockOrder, - state.editor.present.blocksByUid, - ] -); - -/** - * Returns the number of blocks currently present in the post. - * - * @param {Object} state Global application state - * @return {Number} Number of blocks in the post - */ -export function getBlockCount( state ) { - return getBlockUids( state ).length; -} - -/** - * Returns the number of blocks currently selected in the post. - * - * @param {Object} state Global application state - * @return {Number} Number of blocks selected in the post - */ -export function getSelectedBlockCount( state ) { - const multiSelectedBlockCount = getMultiSelectedBlockUids( state ).length; - - if ( multiSelectedBlockCount ) { - return multiSelectedBlockCount; - } - - return state.blockSelection.start ? 1 : 0; -} - -/** - * 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 { start, end } = state.blockSelection; - if ( start !== end || ! start ) { - return null; - } - - return getBlock( state, start ); -} - -/** - * Returns the current multi-selection set of blocks unique IDs, or an empty - * array if there is no multi-selection. - * - * @param {Object} state Global application state - * @return {Array} Multi-selected block unique UDs - */ -export const getMultiSelectedBlockUids = createSelector( - ( state ) => { - const { blockOrder } = state.editor.present; - const { start, end } = state.blockSelection; - if ( start === end ) { - return []; - } - - const startIndex = blockOrder.indexOf( start ); - const endIndex = blockOrder.indexOf( end ); - - if ( startIndex > endIndex ) { - return blockOrder.slice( endIndex, startIndex + 1 ); - } - - return blockOrder.slice( startIndex, endIndex + 1 ); - }, - ( state ) => [ - state.editor.present.blockOrder, - state.blockSelection.start, - state.blockSelection.end, - ], -); - -/** - * 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 const getMultiSelectedBlocks = createSelector( - ( state ) => getMultiSelectedBlockUids( state ).map( ( uid ) => getBlock( state, uid ) ), - ( state ) => [ - state.editor.present.blockOrder, - state.blockSelection.start, - state.blockSelection.end, - state.editor.present.blocksByUid, - state.editor.present.edits.meta, - state.currentPost.meta, - ] -); - -/** - * Returns the unique ID of the first block in the multi-selection set, or null - * if there is no multi-selection. - * - * @param {Object} state Global application state - * @return {?String} First unique block ID in the multi-selection set - */ -export function getFirstMultiSelectedBlockUid( state ) { - return first( getMultiSelectedBlockUids( state ) ) || null; -} - -/** - * Returns the unique ID of the last block in the multi-selection set, or null - * if there is no multi-selection. - * - * @param {Object} state Global application state - * @return {?String} Last unique block ID in the multi-selection set - */ -export function getLastMultiSelectedBlockUid( state ) { - return last( getMultiSelectedBlockUids( state ) ) || null; -} - -/** - * 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 {String} uid Block unique ID - * @return {Boolean} Whether block is first in mult-selection - */ -export function isFirstMultiSelectedBlock( state, uid ) { - return getFirstMultiSelectedBlockUid( state ) === uid; -} - -/** - * Returns true if the unique ID occurs within the block multi-selection, or - * false otherwise. - * - * @param {Object} state Global application state - * @param {String} uid Block unique ID - * @return {Boolean} Whether block is in multi-selection set - */ -export function isBlockMultiSelected( state, uid ) { - return getMultiSelectedBlockUids( state ).indexOf( uid ) !== -1; -} - -/** - * Returns the unique ID of the block which begins the multi-selection set, or - * null if there is no multi-selection. - * - * N.b.: This is not necessarily the first uid in the selection. See - * getFirstMultiSelectedBlockUid(). - * - * @param {Object} state Global application state - * @return {?String} Unique ID of block beginning multi-selection - */ -export function getMultiSelectedBlocksStartUid( state ) { - const { start, end } = state.blockSelection; - if ( start === end ) { - return null; - } - return start || null; -} - -/** - * Returns the unique ID of the block which ends the multi-selection set, or - * null if there is no multi-selection. - * - * N.b.: This is not necessarily the last uid in the selection. See - * getLastMultiSelectedBlockUid(). - * - * @param {Object} state Global application state - * @return {?String} Unique ID of block ending multi-selection - */ -export function getMultiSelectedBlocksEndUid( state ) { - const { start, end } = state.blockSelection; - if ( start === end ) { - return null; - } - return 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.present.blockOrder; -} - -/** - * 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.present.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 {String} uid Block unique ID - * @return {Boolean} Whether block is first in post - */ -export function isFirstBlock( state, uid ) { - return first( state.editor.present.blockOrder ) === 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 {String} uid Block unique ID - * @return {Boolean} Whether block is last in post - */ -export function isLastBlock( state, uid ) { - return last( state.editor.present.blockOrder ) === uid; -} - -/** - * Returns the block object occurring before the one corresponding to the - * specified unique ID. - * - * @param {Object} state Global application state - * @param {String} uid Block unique ID - * @return {Object} Block occurring before specified unique ID - */ -export function getPreviousBlock( state, uid ) { - const order = getBlockIndex( state, uid ); - return state.editor.present.blocksByUid[ state.editor.present.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 {String} uid Block unique ID - * @return {Object} Block occurring after specified unique ID - */ -export function getNextBlock( state, uid ) { - const order = getBlockIndex( state, uid ); - return state.editor.present.blocksByUid[ state.editor.present.blockOrder[ order + 1 ] ] || null; -} - -/** - * Returns true if the block corresponding to the specified unique ID is - * currently selected and no multi-selection exists, or false otherwise. - * - * @param {Object} state Global application state - * @param {String} uid Block unique ID - * @return {Boolean} Whether block is selected and multi-selection exists - */ -export function isBlockSelected( state, uid ) { - const { start, end } = state.blockSelection; - - if ( start !== end ) { - return false; - } - - return start === uid; -} - -/** - * Returns true if the block corresponding to the specified unique ID is - * currently selected but isn't the last of the selected blocks. Here "last" - * refers to the block sequence in the document, _not_ the sequence of - * multi-selection, which is why `state.blockSelection.end` isn't used. - * - * @param {Object} state Global application state - * @param {String} uid Block unique ID - * @return {Boolean} Whether block is selected and not the last in the selection - */ -export function isBlockWithinSelection( state, uid ) { - if ( ! uid ) { - return false; - } - - const uids = getMultiSelectedBlockUids( state ); - const index = uids.indexOf( uid ); - return index > -1 && index < uids.length - 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 {String} 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 {String} uid Block unique ID - * @return {Object} Block focus state - */ -export function getBlockFocus( state, uid ) { - // If there is multi-selection, keep returning the focus object for the start block. - if ( ! isBlockSelected( state, uid ) && state.blockSelection.start !== uid ) { - return null; - } - - return state.blockSelection.focus; -} - -/** - * Whether in the process of multi-selecting or not. - * - * @param {Object} state Global application state - * @return {Boolean} True if multi-selecting, false if not. - */ -export function isMultiSelecting( state ) { - return state.blockSelection.isMultiSelecting; -} - -/** - * Returns thee block's editing mode - * - * @param {Object} state Global application state - * @param {String} uid Block unique ID - * @return {Object} Block editing mode - */ -export function getBlockMode( state, uid ) { - return state.blocksMode[ uid ] || 'visual'; -} - -/** - * Returns true if the user is typing, or false otherwise. - * - * @param {Object} state Global application state - * @return {Boolean} Whether user is typing - */ -export function isTyping( state ) { - return state.isTyping; -} - -/** - * Returns the insertion point, the index at which the new inserted block would - * be placed. Defaults to the last position - * - * @param {Object} state Global application state - * @return {?String} Unique ID after which insertion will occur - */ -export function getBlockInsertionPoint( state ) { - if ( getEditorMode( state ) !== 'visual' ) { - return state.editor.present.blockOrder.length; - } - - const position = getBlockSiblingInserterPosition( state ); - if ( null !== position ) { - return position; - } - - const lastMultiSelectedBlock = getLastMultiSelectedBlockUid( state ); - if ( lastMultiSelectedBlock ) { - return getBlockIndex( state, lastMultiSelectedBlock ) + 1; - } - - const selectedBlock = getSelectedBlock( state ); - if ( selectedBlock ) { - return getBlockIndex( state, selectedBlock.uid ) + 1; - } - - return state.editor.present.blockOrder.length; -} - -/** - * Returns the position at which the block inserter will insert a new adjacent - * sibling block, or null if the inserter is not actively visible. - * - * @param {Object} state Global application state - * @return {?Number} Whether the inserter is currently visible - */ -export function getBlockSiblingInserterPosition( state ) { - const { position } = state.blockInsertionPoint; - if ( ! Number.isInteger( position ) ) { - return null; - } - - return position; -} - -/** - * Returns true if we should show the block insertion point - * - * @param {Object} state Global application state - * @return {?Boolean} Whether the insertion point is visible or not - */ -export function isBlockInsertionPointVisible( state ) { - return !! state.blockInsertionPoint.visible; -} - -/** - * 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 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.present.blockOrder; - - let name; - // If there is only one block in the content of the post grab its name - // so we can derive a suitable post format from it. - if ( blocks.length === 1 ) { - name = state.editor.present.blocksByUid[ blocks[ 0 ] ].name; - } - - // If there are two blocks in the content and the last one is a text blocks - // grab the name of the first one to also suggest a post format from it. - if ( blocks.length === 2 ) { - if ( state.editor.present.blocksByUid[ blocks[ 1 ] ].name === 'core/paragraph' ) { - name = state.editor.present.blocksByUid[ blocks[ 0 ] ].name; - } - } - - // We only convert to default post formats in core. - switch ( name ) { - case 'core/image': - return 'image'; - case 'core/quote': - case 'core/pullquote': - return 'quote'; - case 'core/gallery': - return 'gallery'; - case 'core/video': - case 'core-embed/youtube': - case 'core-embed/vimeo': - return 'video'; - case 'core/audio': - case 'core-embed/spotify': - case 'core-embed/soundcloud': - return 'audio'; - } - - return null; -} - -/** - * Returns the content of the post being edited, preferring raw string edit - * before falling back to serialization of block state. - * - * @param {Object} state Global application state - * @return {String} Post content - */ -export const getEditedPostContent = createSelector( - ( state ) => { - const edits = getPostEdits( state ); - if ( 'content' in edits ) { - return edits.content; - } - - return serialize( getBlocks( state ) ); - }, - ( state ) => [ - state.editor.present.edits.content, - state.editor.present.blocksByUid, - state.editor.present.blockOrder, - ], -); - -/** - * Returns the user notices array - * - * @param {Object} state Global application state - * @return {Array} List of notices - */ -export function getNotices( state ) { - return state.notices; -} - -/** - * Resolves the list of recently used block names into a list of block type settings. - * - * @param {Object} state Global application state - * @return {Array} List of recently used blocks - */ -export function getRecentlyUsedBlocks( state ) { - // resolves the block names in the state to the block type settings - return compact( state.preferences.recentlyUsedBlocks.map( blockType => getBlockType( blockType ) ) ); -} - -/** - * Resolves the block usage stats into a list of the most frequently used blocks. - * Memoized so we're not generating block lists every time we render the list - * in the inserter. - * - * @param {Object} state Global application state - * @return {Array} List of block type settings - */ -export const getMostFrequentlyUsedBlocks = createSelector( - ( state ) => { - const { blockUsage } = state.preferences; - const orderedByUsage = keys( blockUsage ).sort( ( a, b ) => blockUsage[ b ] - blockUsage[ a ] ); - // add in paragraph and image blocks if they're not already in the usage data - return compact( - [ ...orderedByUsage, ...without( [ 'core/paragraph', 'core/image' ], ...orderedByUsage ) ] - .map( blockType => getBlockType( blockType ) ) - ).slice( 0, MAX_FREQUENT_BLOCKS ); - }, - ( state ) => state.preferences.blockUsage -); - -/** - * Returns whether the given feature is enabled or not - * - * @param {Object} state Global application state - * @param {String} feature Feature slug - * @return {Booleean} Is active - */ -export function isFeatureActive( state, feature ) { - return !! state.preferences.features[ feature ]; -} - -/** - * Returns the reusable block with the given ID. - * - * @param {Object} state Global application state - * @param {String} ref The reusable block's ID - * @return {Object} The reusable block, or null if none exists - */ -export function getReusableBlock( state, ref ) { - return state.reusableBlocks.data[ ref ] || null; -} - -/** - * Returns whether or not the reusable block with the given ID is being saved. - * - * @param {*} state Global application state - * @param {*} ref The reusable block's ID - * @return {Boolean} Whether or not the reusable block is being saved - */ -export function isSavingReusableBlock( state, ref ) { - return state.reusableBlocks.isSaving[ ref ] || false; -} - -/** - * Returns an array of all reusable blocks. - * - * @param {Object} state Global application state - * @return {Array} An array of all reusable blocks. - */ -export function getReusableBlocks( state ) { - return Object.values( state.reusableBlocks.data ); -} +export { + getBlockInsertionPoint, + getBlockSiblingInserterPosition, + isBlockInsertionPointVisible, +} from './block-insertion-point'; +export { + getSelectedBlock, + getSelectedBlockCount, + getMultiSelectedBlockUids, + getMultiSelectedBlocks, + getFirstMultiSelectedBlockUid, + getLastMultiSelectedBlockUid, + isFirstMultiSelectedBlock, + isBlockMultiSelected, + getMultiSelectedBlocksStartUid, + getMultiSelectedBlocksEndUid, + isBlockWithinSelection, + isBlockSelected, + getBlockFocus, + isMultiSelecting, +} from './block-selection'; +export { + getBlockMode, +} from './blocks-mode'; +export { + getCurrentPost, + isCurrentPostNew, + getCurrentPostId, + getCurrentPostType, + getCurrentPostRevisionsCount, + getCurrentPostLastRevisionId, + isCurrentPostPublished, + getCurrentPostPreviewLink, +} from './current-post'; +export { + getPostEdits, + isEditedPostDirty, + isCleanNewPost, + isEditedPostPublishable, + getEditedPostAttribute, + getEditedPostVisibility, + isEditedPostBeingScheduled, + isEditedPostSaveable, + getEditedPostTitle, + getBlock, + getBlocks, + getEditedPostExcerpt, + getBlockCount, + getBlockUids, + getBlockIndex, + isFirstBlock, + isLastBlock, + getPreviousBlock, + getNextBlock, + getEditedPostContent, + getSuggestedPostFormat, + hasEditorUndo, + hasEditorRedo, +} from './editor'; +export { + isBlockHovered, +} from './hovered-block'; +export { + isTyping, +} from './is-typing'; +export { + getMetaBoxes, + getMetaBox, + getDirtyMetaBoxes, + isMetaBoxStateDirty, +} from './meta-boxes'; +export { + getNotices, +} from './notices'; +export { + getActivePanel, +} from './panel'; +export { + getEditorMode, + getPreferences, + getPreference, + isEditorSidebarOpened, + isEditorSidebarPanelOpened, + getMostFrequentlyUsedBlocks, + getRecentlyUsedBlocks, + isFeatureActive, +} from './preferences'; +export { + isSavingPost, + didPostSaveRequestSucceed, + didPostSaveRequestFail, +} from './saving'; +export { + getDocumentTitle, +} from './ui'; diff --git a/editor/state/test/actions.js b/editor/state/test/actions.js deleted file mode 100644 index cc8b8d05f917f5..00000000000000 --- a/editor/state/test/actions.js +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Internal dependencies - */ -import { - focusBlock, - replaceBlocks, - startTyping, - stopTyping, - requestMetaBoxUpdates, - handleMetaBoxReload, - metaBoxStateChanged, - initializeMetaBoxState, - fetchReusableBlocks, - updateReusableBlock, - saveReusableBlock, - convertBlockToStatic, - convertBlockToReusable, -} from '../actions'; - -describe( 'actions', () => { - describe( 'focusBlock', () => { - it( 'should return the UPDATE_FOCUS action', () => { - const focusConfig = { - editable: 'cite', - }; - - expect( focusBlock( 'chicken', focusConfig ) ).toEqual( { - type: 'UPDATE_FOCUS', - uid: 'chicken', - config: focusConfig, - } ); - } ); - } ); - - describe( 'replaceBlocks', () => { - it( 'should return the REPLACE_BLOCKS action', () => { - const blocks = [ { - uid: 'ribs', - } ]; - - expect( replaceBlocks( [ 'chicken' ], blocks ) ).toEqual( { - type: 'REPLACE_BLOCKS', - uids: [ 'chicken' ], - blocks, - } ); - } ); - } ); - - describe( 'startTyping', () => { - it( 'should return the START_TYPING action', () => { - expect( startTyping() ).toEqual( { - type: 'START_TYPING', - } ); - } ); - } ); - - describe( 'stopTyping', () => { - it( 'should return the STOP_TYPING action', () => { - expect( stopTyping() ).toEqual( { - type: 'STOP_TYPING', - } ); - } ); - } ); - - describe( 'requestMetaBoxUpdates', () => { - it( 'should return the REQUEST_META_BOX_UPDATES action', () => { - expect( requestMetaBoxUpdates( [ 'normal' ] ) ).toEqual( { - type: 'REQUEST_META_BOX_UPDATES', - locations: [ 'normal' ], - } ); - } ); - } ); - - describe( 'handleMetaBoxReload', () => { - it( 'should return the HANDLE_META_BOX_RELOAD action with a location and node', () => { - expect( handleMetaBoxReload( 'normal' ) ).toEqual( { - type: 'HANDLE_META_BOX_RELOAD', - location: 'normal', - } ); - } ); - } ); - - describe( 'metaBoxStateChanged', () => { - it( 'should return the META_BOX_STATE_CHANGED action with a hasChanged flag', () => { - expect( metaBoxStateChanged( 'normal', true ) ).toEqual( { - type: 'META_BOX_STATE_CHANGED', - location: 'normal', - hasChanged: true, - } ); - } ); - } ); - - describe( 'initializeMetaBoxState', () => { - it( 'should return the META_BOX_STATE_CHANGED action with a hasChanged flag', () => { - const metaBoxes = { - side: true, - normal: true, - advanced: false, - }; - - expect( initializeMetaBoxState( metaBoxes ) ).toEqual( { - type: 'INITIALIZE_META_BOX_STATE', - metaBoxes, - } ); - } ); - } ); - - describe( 'fetchReusableBlocks', () => { - it( 'should return the FETCH_REUSABLE_BLOCKS action', () => { - expect( fetchReusableBlocks() ).toEqual( { - type: 'FETCH_REUSABLE_BLOCKS', - } ); - } ); - - it( 'should take an optional id argument', () => { - const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - expect( fetchReusableBlocks( id ) ).toEqual( { - type: 'FETCH_REUSABLE_BLOCKS', - id, - } ); - } ); - } ); - - describe( 'updateReusableBlock', () => { - it( 'should return the UPDATE_REUSABLE_BLOCK action', () => { - const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - const reusableBlock = { - id, - name: 'My cool block', - type: 'core/paragraph', - attributes: { - content: 'Hello!', - }, - }; - expect( updateReusableBlock( id, reusableBlock ) ).toEqual( { - type: 'UPDATE_REUSABLE_BLOCK', - id, - reusableBlock, - } ); - } ); - } ); - - describe( 'saveReusableBlock', () => { - const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - expect( saveReusableBlock( id ) ).toEqual( { - type: 'SAVE_REUSABLE_BLOCK', - id, - } ); - } ); - - describe( 'convertBlockToStatic', () => { - const uid = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - expect( convertBlockToStatic( uid ) ).toEqual( { - type: 'CONVERT_BLOCK_TO_STATIC', - uid, - } ); - } ); - - describe( 'convertBlockToReusable', () => { - const uid = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - expect( convertBlockToReusable( uid ) ).toEqual( { - type: 'CONVERT_BLOCK_TO_REUSABLE', - uid, - } ); - } ); -} ); diff --git a/editor/state/test/block-insertion-point.js b/editor/state/test/block-insertion-point.js new file mode 100644 index 00000000000000..f42d43230051fe --- /dev/null +++ b/editor/state/test/block-insertion-point.js @@ -0,0 +1,205 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import reducer, { + getBlockInsertionPoint, + getBlockSiblingInserterPosition, + isBlockInsertionPointVisible, +} from '../block-insertion-point'; + +describe( 'blockInsertionPoint', () => { + describe( 'reducer', () => { + it( 'should default to an empty object', () => { + const state = reducer( undefined, {} ); + + expect( state ).toEqual( {} ); + } ); + + it( 'should set insertion point position', () => { + const state = reducer( undefined, { + type: 'SET_BLOCK_INSERTION_POINT', + position: 5, + } ); + + expect( state ).toEqual( { + position: 5, + } ); + } ); + + it( 'should clear insertion point position', () => { + const original = reducer( undefined, { + type: 'SET_BLOCK_INSERTION_POINT', + position: 5, + } ); + + const state = reducer( deepFreeze( original ), { + type: 'CLEAR_BLOCK_INSERTION_POINT', + } ); + + expect( state ).toEqual( { + position: null, + } ); + } ); + + it( 'should show the insertion point', () => { + const state = reducer( undefined, { + type: 'SHOW_INSERTION_POINT', + } ); + + expect( state ).toEqual( { visible: true } ); + } ); + + it( 'should clear the insertion point', () => { + const state = reducer( deepFreeze( {} ), { + type: 'HIDE_INSERTION_POINT', + } ); + + expect( state ).toEqual( { visible: false } ); + } ); + + it( 'should merge position and visible', () => { + const original = reducer( undefined, { + type: 'SHOW_INSERTION_POINT', + } ); + + const state = reducer( deepFreeze( original ), { + type: 'SET_BLOCK_INSERTION_POINT', + position: 5, + } ); + + expect( state ).toEqual( { + visible: true, + position: 5, + } ); + } ); + } ); + + describe( 'selectors', () => { + describe( 'getBlockInsertionPoint', () => { + it( 'should return the uid of the selected block', () => { + const state = { + currentPost: {}, + preferences: { mode: 'visual' }, + blockSelection: { + start: 2, + end: 2, + }, + editor: { + present: { + blocksByUid: { + 2: { uid: 2 }, + }, + blockOrder: [ 1, 2, 3 ], + edits: {}, + }, + }, + blockInsertionPoint: {}, + }; + + expect( getBlockInsertionPoint( state ) ).toBe( 2 ); + } ); + + it( 'should return the assigned insertion point', () => { + const state = { + preferences: { mode: 'visual' }, + blockSelection: {}, + editor: { + present: { + blockOrder: [ 1, 2, 3 ], + }, + }, + blockInsertionPoint: { + position: 2, + }, + }; + + expect( getBlockInsertionPoint( state ) ).toBe( 2 ); + } ); + + it( 'should return the last multi selected uid', () => { + const state = { + preferences: { mode: 'visual' }, + blockSelection: { + start: 1, + end: 2, + }, + editor: { + present: { + blockOrder: [ 1, 2, 3 ], + }, + }, + blockInsertionPoint: {}, + }; + + expect( getBlockInsertionPoint( state ) ).toBe( 2 ); + } ); + + it( 'should return the last block if no selection', () => { + const state = { + preferences: { mode: 'visual' }, + blockSelection: { start: null, end: null }, + editor: { + present: { + blockOrder: [ 1, 2, 3 ], + }, + }, + blockInsertionPoint: {}, + }; + + expect( getBlockInsertionPoint( state ) ).toBe( 3 ); + } ); + + it( 'should return the last block for the text mode', () => { + const state = { + preferences: { mode: 'text' }, + blockSelection: { start: 2, end: 2 }, + editor: { + present: { + blockOrder: [ 1, 2, 3 ], + }, + }, + blockInsertionPoint: {}, + }; + + expect( getBlockInsertionPoint( state ) ).toBe( 3 ); + } ); + } ); + + describe( 'getBlockSiblingInserterPosition', () => { + it( 'should return null if no sibling insertion point', () => { + const state = { + blockInsertionPoint: {}, + }; + + expect( getBlockSiblingInserterPosition( state ) ).toBe( null ); + } ); + + it( 'should return sibling insertion point', () => { + const state = { + blockInsertionPoint: { + position: 5, + }, + }; + + expect( getBlockSiblingInserterPosition( state ) ).toBe( 5 ); + } ); + } ); + + describe( 'isBlockInsertionPointVisible', () => { + it( 'should return the value in state', () => { + const state = { + blockInsertionPoint: { + visible: true, + }, + }; + + expect( isBlockInsertionPointVisible( state ) ).toBe( true ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/block-selection.js b/editor/state/test/block-selection.js new file mode 100644 index 00000000000000..52408e5bd27504 --- /dev/null +++ b/editor/state/test/block-selection.js @@ -0,0 +1,500 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import reducer, { + focusBlock, + getMultiSelectedBlockUids, + getMultiSelectedBlocks, + getSelectedBlock, + getMultiSelectedBlocksStartUid, + getMultiSelectedBlocksEndUid, + isBlockSelected, + isBlockWithinSelection, + isBlockMultiSelected, + isFirstMultiSelectedBlock, + getBlockFocus, +} from '../block-selection'; + +describe( 'blockSelection', () => { + describe( 'reducer', () => { + it( 'should return with block uid as selected', () => { + const state = reducer( undefined, { + type: 'SELECT_BLOCK', + uid: 'kumquat', + } ); + + expect( state ).toEqual( { start: 'kumquat', end: 'kumquat', focus: {}, isMultiSelecting: false } ); + } ); + + it( 'should set multi selection', () => { + const original = deepFreeze( { focus: { editable: 'citation' }, isMultiSelecting: false } ); + const state = reducer( original, { + type: 'MULTI_SELECT', + start: 'ribs', + end: 'chicken', + } ); + + expect( state ).toEqual( { start: 'ribs', end: 'chicken', focus: null, isMultiSelecting: false } ); + } ); + + it( 'should set continuous multi selection', () => { + const original = deepFreeze( { focus: { editable: 'citation' }, isMultiSelecting: true } ); + const state = reducer( original, { + type: 'MULTI_SELECT', + start: 'ribs', + end: 'chicken', + } ); + + expect( state ).toEqual( { start: 'ribs', end: 'chicken', focus: { editable: 'citation' }, isMultiSelecting: true } ); + } ); + + it( 'should start multi selection', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs', focus: { editable: 'citation' }, isMultiSelecting: false } ); + const state = reducer( original, { + type: 'START_MULTI_SELECT', + } ); + + expect( state ).toEqual( { start: 'ribs', end: 'ribs', focus: { editable: 'citation' }, isMultiSelecting: true } ); + } ); + + it( 'should end multi selection with selection', () => { + const original = deepFreeze( { start: 'ribs', end: 'chicken', focus: { editable: 'citation' }, isMultiSelecting: true } ); + const state = reducer( original, { + type: 'STOP_MULTI_SELECT', + } ); + + expect( state ).toEqual( { start: 'ribs', end: 'chicken', focus: null, isMultiSelecting: false } ); + } ); + + it( 'should end multi selection without selection', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs', focus: { editable: 'citation' }, isMultiSelecting: true } ); + const state = reducer( original, { + type: 'STOP_MULTI_SELECT', + } ); + + expect( state ).toEqual( { start: 'ribs', end: 'ribs', focus: { editable: 'citation' }, isMultiSelecting: false } ); + } ); + + it( 'should not update the state if the block is already selected', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs' } ); + + const state1 = reducer( original, { + type: 'SELECT_BLOCK', + uid: 'ribs', + } ); + + expect( state1 ).toBe( original ); + } ); + + it( 'should unset multi selection and select inserted block', () => { + const original = deepFreeze( { start: 'ribs', end: 'chicken' } ); + + const state1 = reducer( original, { + type: 'CLEAR_SELECTED_BLOCK', + } ); + + expect( state1 ).toEqual( { start: null, end: null, focus: null, isMultiSelecting: false } ); + + const state3 = reducer( original, { + type: 'INSERT_BLOCKS', + blocks: [ { + uid: 'ribs', + name: 'core/freeform', + } ], + } ); + + expect( state3 ).toEqual( { start: 'ribs', end: 'ribs', focus: {}, isMultiSelecting: false } ); + } ); + + it( 'should not update the state if the block moved is already selected', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs', focus: {} } ); + const state = reducer( original, { + type: 'MOVE_BLOCKS_UP', + uids: [ 'ribs' ], + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should update the focus and selects the block', () => { + const state = reducer( undefined, { + type: 'UPDATE_FOCUS', + uid: 'chicken', + config: { editable: 'citation' }, + } ); + + expect( state ).toEqual( { start: 'chicken', end: 'chicken', focus: { editable: 'citation' }, isMultiSelecting: false } ); + } ); + + it( 'should update the focus and merge the existing state', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs', focus: {}, isMultiSelecting: true } ); + const state = reducer( original, { + type: 'UPDATE_FOCUS', + uid: 'ribs', + config: { editable: 'citation' }, + } ); + + expect( state ).toEqual( { start: 'ribs', end: 'ribs', focus: { editable: 'citation' }, isMultiSelecting: true } ); + } ); + + it( 'should replace the selected block', () => { + const original = deepFreeze( { start: 'chicken', end: 'chicken', focus: { editable: 'citation' } } ); + const state = reducer( original, { + type: 'REPLACE_BLOCKS', + uids: [ 'chicken' ], + blocks: [ { + uid: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toEqual( { start: 'wings', end: 'wings', focus: {}, isMultiSelecting: false } ); + } ); + + it( 'should keep the selected block', () => { + const original = deepFreeze( { start: 'chicken', end: 'chicken', focus: { editable: 'citation' } } ); + const state = reducer( original, { + type: 'REPLACE_BLOCKS', + uids: [ 'ribs' ], + blocks: [ { + uid: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toBe( original ); + } ); + } ); + + describe( 'action creators', () => { + describe( 'focusBlock', () => { + it( 'should return the UPDATE_FOCUS action', () => { + const focusConfig = { + editable: 'cite', + }; + + expect( focusBlock( 'chicken', focusConfig ) ).toEqual( { + type: 'UPDATE_FOCUS', + uid: 'chicken', + config: focusConfig, + } ); + } ); + } ); + } ); + + describe( 'selectors', () => { + beforeEach( () => { + getMultiSelectedBlockUids.clear(); + getMultiSelectedBlocks.clear(); + } ); + + describe( 'getSelectedBlock', () => { + it( 'should return null if no block is selected', () => { + const state = { + currentPost: {}, + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + edits: {}, + }, + }, + blockSelection: { start: null, end: null }, + }; + + expect( getSelectedBlock( state ) ).toBe( null ); + } ); + + it( 'should return null if there is multi selection', () => { + const state = { + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + }, + }, + blockSelection: { start: 23, end: 123 }, + }; + + expect( getSelectedBlock( state ) ).toBe( null ); + } ); + + it( 'should return the selected block', () => { + const state = { + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + }, + }, + blockSelection: { start: 23, end: 23 }, + }; + + expect( getSelectedBlock( state ) ).toBe( state.editor.present.blocksByUid[ 23 ] ); + } ); + } ); + + describe( 'getMultiSelectedBlockUids', () => { + it( 'should return empty if there is no multi selection', () => { + const state = { + editor: { + present: { + blockOrder: [ 123, 23 ], + }, + }, + blockSelection: { start: null, end: null }, + }; + + expect( getMultiSelectedBlockUids( state ) ).toEqual( [] ); + } ); + + it( 'should return selected block uids if there is multi selection', () => { + const state = { + editor: { + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + }, + blockSelection: { start: 2, end: 4 }, + }; + + expect( getMultiSelectedBlockUids( state ) ).toEqual( [ 4, 3, 2 ] ); + } ); + } ); + + describe( 'getMultiSelectedBlocksStartUid', () => { + it( 'returns null if there is no multi selection', () => { + const state = { + editor: { + present: { + blockOrder: [ 123, 23 ], + }, + }, + blockSelection: { start: null, end: null }, + }; + + expect( getMultiSelectedBlocksStartUid( state ) ).toBeNull(); + } ); + + it( 'returns multi selection start', () => { + const state = { + editor: { + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + }, + blockSelection: { start: 2, end: 4 }, + }; + + expect( getMultiSelectedBlocksStartUid( state ) ).toBe( 2 ); + } ); + } ); + + describe( 'getMultiSelectedBlocksEndUid', () => { + it( 'returns null if there is no multi selection', () => { + const state = { + editor: { + present: { + blockOrder: [ 123, 23 ], + }, + }, + blockSelection: { start: null, end: null }, + }; + + expect( getMultiSelectedBlocksEndUid( state ) ).toBeNull(); + } ); + + it( 'returns multi selection end', () => { + const state = { + editor: { + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + }, + blockSelection: { start: 2, end: 4 }, + }; + + expect( getMultiSelectedBlocksEndUid( state ) ).toBe( 4 ); + } ); + } ); + + describe( 'isBlockSelected', () => { + it( 'should return true if the block is selected', () => { + const state = { + blockSelection: { start: 123, end: 123 }, + }; + + expect( isBlockSelected( state, 123 ) ).toBe( true ); + } ); + + it( 'should return false if a multi-selection range exists', () => { + const state = { + blockSelection: { start: 123, end: 124 }, + }; + + expect( isBlockSelected( state, 123 ) ).toBe( false ); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + blockSelection: { start: null, end: null }, + }; + + expect( isBlockSelected( state, 23 ) ).toBe( false ); + } ); + } ); + + describe( 'isBlockWithinSelection', () => { + it( 'should return true if the block is selected but not the last', () => { + const state = { + blockSelection: { start: 5, end: 3 }, + editor: { + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 4 ) ).toBe( true ); + } ); + + it( 'should return false if the block is the last selected', () => { + const state = { + blockSelection: { start: 5, end: 3 }, + editor: { + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 3 ) ).toBe( false ); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + blockSelection: { start: 5, end: 3 }, + editor: { + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 2 ) ).toBe( false ); + } ); + + it( 'should return false if there is no selection', () => { + const state = { + blockSelection: {}, + editor: { + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 4 ) ).toBe( false ); + } ); + } ); + + describe( 'isBlockMultiSelected', () => { + const state = { + editor: { + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + }, + blockSelection: { start: 2, end: 4 }, + }; + + it( 'should return true if the block is multi selected', () => { + expect( isBlockMultiSelected( state, 3 ) ).toBe( true ); + } ); + + it( 'should return false if the block is not multi selected', () => { + expect( isBlockMultiSelected( state, 5 ) ).toBe( false ); + } ); + } ); + + describe( 'isFirstMultiSelectedBlock', () => { + const state = { + editor: { + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + }, + blockSelection: { start: 2, end: 4 }, + }; + + it( 'should return true if the block is first in multi selection', () => { + expect( isFirstMultiSelectedBlock( state, 4 ) ).toBe( true ); + } ); + + it( 'should return false if the block is not first in multi selection', () => { + expect( isFirstMultiSelectedBlock( state, 3 ) ).toBe( false ); + } ); + } ); + + describe( 'getBlockFocus', () => { + it( 'should return the block focus if the block is selected', () => { + const state = { + blockSelection: { + start: 123, + end: 123, + focus: { editable: 'cite' }, + }, + }; + + expect( getBlockFocus( state, 123 ) ).toEqual( { editable: 'cite' } ); + } ); + + it( 'should return the block focus for the start if the block is multi-selected', () => { + const state = { + blockSelection: { + start: 123, + end: 124, + focus: { editable: 'cite' }, + }, + }; + + expect( getBlockFocus( state, 123 ) ).toEqual( { editable: 'cite' } ); + } ); + + it( 'should return null for the end if the block is multi-selected', () => { + const state = { + blockSelection: { + start: 123, + end: 124, + focus: { editable: 'cite' }, + }, + }; + + expect( getBlockFocus( state, 124 ) ).toEqual( null ); + } ); + + it( 'should return null if the block is not selected', () => { + const state = { + blockSelection: { + start: 123, + end: 123, + focus: { editable: 'cite' }, + }, + }; + + expect( getBlockFocus( state, 23 ) ).toEqual( null ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/blocks-mode.js b/editor/state/test/blocks-mode.js new file mode 100644 index 00000000000000..e1d1bfe0224195 --- /dev/null +++ b/editor/state/test/blocks-mode.js @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import reducer, { getBlockMode } from '../blocks-mode'; + +describe( 'blocksMode', () => { + describe( 'reducer', () => { + it( 'should set mode to html if not set', () => { + const action = { + type: 'TOGGLE_BLOCK_MODE', + uid: 'chicken', + }; + const value = reducer( deepFreeze( {} ), action ); + + expect( value ).toEqual( { chicken: 'html' } ); + } ); + + it( 'should toggle mode to visual if set as html', () => { + const action = { + type: 'TOGGLE_BLOCK_MODE', + uid: 'chicken', + }; + const value = reducer( deepFreeze( { chicken: 'html' } ), action ); + + expect( value ).toEqual( { chicken: 'visual' } ); + } ); + } ); + + describe( 'selectors', () => { + describe( 'getBlockMode', () => { + it( 'should return "visual" if unset', () => { + const state = { + blocksMode: {}, + }; + + expect( getBlockMode( state, 123 ) ).toEqual( 'visual' ); + } ); + + it( 'should return the block mode', () => { + const state = { + blocksMode: { + 123: 'html', + }, + }; + + expect( getBlockMode( state, 123 ) ).toEqual( 'html' ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/current-post.js b/editor/state/test/current-post.js new file mode 100644 index 00000000000000..284fdb9e34fe9a --- /dev/null +++ b/editor/state/test/current-post.js @@ -0,0 +1,234 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import reducer, { + isCurrentPostNew, + getCurrentPost, + getCurrentPostId, + getCurrentPostLastRevisionId, + getCurrentPostRevisionsCount, + getCurrentPostType, + isCurrentPostPublished, + getCurrentPostPreviewLink, +} from '../current-post'; + +describe( 'currentPost', () => { + describe( 'reducer', () => { + it( 'should reset a post object', () => { + const original = deepFreeze( { title: 'unmodified' } ); + + const state = reducer( original, { + type: 'RESET_POST', + post: { + title: 'new post', + }, + } ); + + expect( state ).toEqual( { + title: 'new post', + } ); + } ); + + it( 'should update the post object with UPDATE_POST', () => { + const original = deepFreeze( { title: 'unmodified', status: 'publish' } ); + + const state = reducer( original, { + type: 'UPDATE_POST', + edits: { + title: 'updated post object from server', + }, + } ); + + expect( state ).toEqual( { + title: 'updated post object from server', + status: 'publish', + } ); + } ); + } ); + + describe( 'selectors', () => { + describe( 'isCurrentPostNew', () => { + it( 'should return true when the post is new', () => { + const state = { + currentPost: { + status: 'auto-draft', + }, + editor: { + present: { + edits: {}, + }, + }, + }; + + expect( isCurrentPostNew( state ) ).toBe( true ); + } ); + + it( 'should return false when the post is not new', () => { + const state = { + currentPost: { + status: 'draft', + }, + editor: { + present: { + edits: {}, + }, + }, + }; + + expect( isCurrentPostNew( state ) ).toBe( false ); + } ); + } ); + + describe( 'getCurrentPost', () => { + it( 'should return the current post', () => { + const state = { + currentPost: { id: 1 }, + }; + + expect( getCurrentPost( state ) ).toEqual( { id: 1 } ); + } ); + } ); + + describe( 'getCurrentPostId', () => { + it( 'should return null if the post has not yet been saved', () => { + const state = { + currentPost: {}, + }; + + expect( getCurrentPostId( state ) ).toBeNull(); + } ); + + it( 'should return the current post ID', () => { + const state = { + currentPost: { id: 1 }, + }; + + expect( getCurrentPostId( state ) ).toBe( 1 ); + } ); + } ); + + describe( 'getCurrentPostLastRevisionId', () => { + it( 'should return null if the post has not yet been saved', () => { + const state = { + currentPost: {}, + }; + + expect( getCurrentPostLastRevisionId( state ) ).toBeNull(); + } ); + + it( 'should return the last revision ID', () => { + const state = { + currentPost: { + revisions: { + last_id: 123, + }, + }, + }; + + expect( getCurrentPostLastRevisionId( state ) ).toBe( 123 ); + } ); + } ); + + describe( 'getCurrentPostRevisionsCount', () => { + it( 'should return 0 if the post has no revisions', () => { + const state = { + currentPost: {}, + }; + + expect( getCurrentPostRevisionsCount( state ) ).toBe( 0 ); + } ); + + it( 'should return the number of revisions', () => { + const state = { + currentPost: { + revisions: { + count: 5, + }, + }, + }; + + expect( getCurrentPostRevisionsCount( state ) ).toBe( 5 ); + } ); + } ); + + describe( 'getCurrentPostType', () => { + it( 'should return the post type', () => { + const state = { + currentPost: { + type: 'post', + }, + }; + + expect( getCurrentPostType( state ) ).toBe( 'post' ); + } ); + } ); + + describe( 'isCurrentPostPublished', () => { + it( 'should return true for public posts', () => { + const state = { + currentPost: { + status: 'publish', + }, + }; + + expect( isCurrentPostPublished( state ) ).toBe( true ); + } ); + + it( 'should return true for private posts', () => { + const state = { + currentPost: { + status: 'private', + }, + }; + + expect( isCurrentPostPublished( state ) ).toBe( true ); + } ); + + it( 'should return false for draft posts', () => { + const state = { + currentPost: { + status: 'draft', + }, + }; + + expect( isCurrentPostPublished( state ) ).toBe( false ); + } ); + + it( 'should return true for old scheduled posts', () => { + const state = { + currentPost: { + status: 'future', + date: '2016-05-30T17:21:39', + }, + }; + + expect( isCurrentPostPublished( state ) ).toBe( true ); + } ); + } ); + + describe( 'getCurrentPostPreviewLink', () => { + it( 'should return null if the post has not link yet', () => { + const state = { + currentPost: {}, + }; + + expect( getCurrentPostPreviewLink( state ) ).toBeNull(); + } ); + + it( 'should return the correct url adding a preview parameter to the query string', () => { + const state = { + currentPost: { + link: 'https://andalouses.com/beach', + }, + }; + + expect( getCurrentPostPreviewLink( state ) ).toBe( 'https://andalouses.com/beach?preview=true' ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/editor.js b/editor/state/test/editor.js new file mode 100644 index 00000000000000..ccf5dcce1af583 --- /dev/null +++ b/editor/state/test/editor.js @@ -0,0 +1,1453 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; +import moment from 'moment'; +import { values, noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import reducer, { + replaceBlocks, + hasEditorUndo, + hasEditorRedo, + getPostEdits, + isEditedPostDirty, + isCleanNewPost, + getEditedPostTitle, + getEditedPostExcerpt, + getEditedPostVisibility, + isEditedPostPublishable, + isEditedPostSaveable, + isEditedPostBeingScheduled, + getBlock, + getBlocks, + getBlockCount, + getBlockUids, + getBlockIndex, + isFirstBlock, + isLastBlock, + getPreviousBlock, + getNextBlock, + getEditedPostContent, + getSuggestedPostFormat, +} from '../editor'; +import { getDirtyMetaBoxes } from '../meta-boxes'; + +describe( 'editor', () => { + beforeAll( () => { + registerBlockType( 'core/test-block', { + save: ( props ) => props.attributes.text, + edit: noop, + category: 'common', + title: 'test block', + } ); + } ); + + afterAll( () => { + unregisterBlockType( 'core/test-block' ); + } ); + + describe( 'reducer', () => { + it( 'should return history (empty edits, blocksByUid, blockOrder), dirty flag by default', () => { + const state = reducer( undefined, {} ); + + expect( state.past ).toEqual( [] ); + expect( state.future ).toEqual( [] ); + expect( state.present.edits ).toEqual( {} ); + expect( state.present.blocksByUid ).toEqual( {} ); + expect( state.present.blockOrder ).toEqual( [] ); + expect( state.isDirty ).toBe( false ); + } ); + + it( 'should key by replaced blocks uid', () => { + const original = reducer( undefined, {} ); + const state = reducer( original, { + type: 'RESET_BLOCKS', + blocks: [ { uid: 'bananas' } ], + } ); + + expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 1 ); + expect( values( state.present.blocksByUid )[ 0 ].uid ).toBe( 'bananas' ); + expect( state.present.blockOrder ).toEqual( [ 'bananas' ] ); + } ); + + it( 'should insert block', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + } ], + } ); + const state = reducer( original, { + type: 'INSERT_BLOCKS', + blocks: [ { + uid: 'ribs', + name: 'core/freeform', + } ], + } ); + + expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 2 ); + expect( values( state.present.blocksByUid )[ 1 ].uid ).toBe( 'ribs' ); + expect( state.present.blockOrder ).toEqual( [ 'chicken', 'ribs' ] ); + } ); + + it( 'should replace the block', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + } ], + } ); + const state = reducer( original, { + type: 'REPLACE_BLOCKS', + uids: [ 'chicken' ], + blocks: [ { + uid: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 1 ); + expect( values( state.present.blocksByUid )[ 0 ].name ).toBe( 'core/freeform' ); + expect( values( state.present.blocksByUid )[ 0 ].uid ).toBe( 'wings' ); + expect( state.present.blockOrder ).toEqual( [ 'wings' ] ); + } ); + + it( 'should update the block', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + isValid: false, + } ], + } ); + const state = reducer( deepFreeze( original ), { + type: 'UPDATE_BLOCK', + uid: 'chicken', + updates: { + attributes: { content: 'ribs' }, + isValid: true, + }, + } ); + + expect( state.present.blocksByUid.chicken ).toEqual( { + uid: 'chicken', + name: 'core/test-block', + attributes: { content: 'ribs' }, + isValid: true, + } ); + } ); + + it( 'should move the block up', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + } ], + } ); + const state = reducer( original, { + type: 'MOVE_BLOCKS_UP', + uids: [ 'ribs' ], + } ); + + expect( state.present.blockOrder ).toEqual( [ 'ribs', 'chicken' ] ); + } ); + + it( 'should move multiple blocks up', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'veggies', + name: 'core/test-block', + attributes: {}, + } ], + } ); + const state = reducer( original, { + type: 'MOVE_BLOCKS_UP', + uids: [ 'ribs', 'veggies' ], + } ); + + expect( state.present.blockOrder ).toEqual( [ 'ribs', 'veggies', 'chicken' ] ); + } ); + + it( 'should not move the first block up', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + } ], + } ); + const state = reducer( original, { + type: 'MOVE_BLOCKS_UP', + uids: [ 'chicken' ], + } ); + + expect( state.present.blockOrder ).toBe( original.present.blockOrder ); + } ); + + it( 'should move the block down', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + } ], + } ); + const state = reducer( original, { + type: 'MOVE_BLOCKS_DOWN', + uids: [ 'chicken' ], + } ); + + expect( state.present.blockOrder ).toEqual( [ 'ribs', 'chicken' ] ); + } ); + + it( 'should move multiple blocks down', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'veggies', + name: 'core/test-block', + attributes: {}, + } ], + } ); + const state = reducer( original, { + type: 'MOVE_BLOCKS_DOWN', + uids: [ 'chicken', 'ribs' ], + } ); + + expect( state.present.blockOrder ).toEqual( [ 'veggies', 'chicken', 'ribs' ] ); + } ); + + it( 'should not move the last block down', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + } ], + } ); + const state = reducer( original, { + type: 'MOVE_BLOCKS_DOWN', + uids: [ 'ribs' ], + } ); + + expect( state.present.blockOrder ).toBe( original.present.blockOrder ); + } ); + + it( 'should remove the block', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + } ], + } ); + const state = reducer( original, { + type: 'REMOVE_BLOCKS', + uids: [ 'chicken' ], + } ); + + expect( state.present.blockOrder ).toEqual( [ 'ribs' ] ); + expect( state.present.blocksByUid ).toEqual( { + ribs: { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + }, + } ); + } ); + + it( 'should remove multiple blocks', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'veggies', + name: 'core/test-block', + attributes: {}, + } ], + } ); + const state = reducer( original, { + type: 'REMOVE_BLOCKS', + uids: [ 'chicken', 'veggies' ], + } ); + + expect( state.present.blockOrder ).toEqual( [ 'ribs' ] ); + expect( state.present.blocksByUid ).toEqual( { + ribs: { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + }, + } ); + } ); + + it( 'should insert at the specified position', () => { + const original = reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'kumquat', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'loquat', + name: 'core/test-block', + attributes: {}, + } ], + } ); + + const state = reducer( original, { + type: 'INSERT_BLOCKS', + position: 1, + blocks: [ { + uid: 'persimmon', + name: 'core/freeform', + } ], + } ); + + expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 3 ); + expect( state.present.blockOrder ).toEqual( [ 'kumquat', 'persimmon', 'loquat' ] ); + } ); + + describe( 'edits()', () => { + it( 'should save newly edited properties', () => { + const original = reducer( undefined, { + type: 'EDIT_POST', + edits: { + status: 'draft', + title: 'post title', + }, + } ); + + const state = reducer( original, { + type: 'EDIT_POST', + edits: { + tags: [ 1 ], + }, + } ); + + expect( state.present.edits ).toEqual( { + status: 'draft', + title: 'post title', + tags: [ 1 ], + } ); + } ); + + it( 'should return same reference if no changed properties', () => { + const original = reducer( undefined, { + type: 'EDIT_POST', + edits: { + status: 'draft', + title: 'post title', + }, + } ); + + const state = reducer( original, { + type: 'EDIT_POST', + edits: { + status: 'draft', + }, + } ); + + expect( state.present.edits ).toBe( original.present.edits ); + } ); + + it( 'should save modified properties', () => { + const original = reducer( undefined, { + type: 'EDIT_POST', + edits: { + status: 'draft', + title: 'post title', + tags: [ 1 ], + }, + } ); + + const state = reducer( original, { + type: 'EDIT_POST', + edits: { + title: 'modified title', + tags: [ 2 ], + }, + } ); + + expect( state.present.edits ).toEqual( { + status: 'draft', + title: 'modified title', + tags: [ 2 ], + } ); + } ); + + it( 'should save initial post state', () => { + const state = reducer( undefined, { + type: 'SETUP_NEW_POST', + edits: { + status: 'draft', + title: 'post title', + }, + } ); + + expect( state.present.edits ).toEqual( { + status: 'draft', + title: 'post title', + } ); + } ); + + it( 'should omit content when resetting', () => { + // Use case: When editing in Text mode, we defer to content on + // the property, but we reset blocks by parse when switching + // back to Visual mode. + const original = deepFreeze( reducer( undefined, {} ) ); + let state = reducer( original, { + type: 'EDIT_POST', + edits: { + content: 'bananas', + }, + } ); + + expect( state.present.edits ).toHaveProperty( 'content' ); + + state = reducer( original, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'kumquat', + name: 'core/test-block', + attributes: {}, + }, { + uid: 'loquat', + name: 'core/test-block', + attributes: {}, + } ], + } ); + + expect( state.present.edits ).not.toHaveProperty( 'content' ); + } ); + } ); + + describe( 'blocksByUid', () => { + it( 'should return with attribute block updates', () => { + const original = deepFreeze( reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'kumquat', + attributes: {}, + } ], + } ) ); + const state = reducer( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + uid: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.present.blocksByUid.kumquat.attributes.updated ).toBe( true ); + } ); + + it( 'should accumulate attribute block updates', () => { + const original = deepFreeze( reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'kumquat', + attributes: { + updated: true, + }, + } ], + } ) ); + const state = reducer( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + uid: 'kumquat', + attributes: { + moreUpdated: true, + }, + } ); + + expect( state.present.blocksByUid.kumquat.attributes ).toEqual( { + updated: true, + moreUpdated: true, + } ); + } ); + + it( 'should ignore updates to non-existant block', () => { + const original = deepFreeze( reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [], + } ) ); + const state = reducer( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + uid: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.present.blocksByUid ).toBe( original.present.blocksByUid ); + } ); + + it( 'should return with same reference if no changes in updates', () => { + const original = deepFreeze( reducer( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'kumquat', + attributes: { + updated: true, + }, + } ], + } ) ); + const state = reducer( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + uid: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.present.blocksByUid ).toBe( state.present.blocksByUid ); + } ); + } ); + } ); + + describe( 'action creators', () => { + describe( 'replaceBlocks', () => { + it( 'should return the REPLACE_BLOCKS action', () => { + const blocks = [ { + uid: 'ribs', + } ]; + + expect( replaceBlocks( [ 'chicken' ], blocks ) ).toEqual( { + type: 'REPLACE_BLOCKS', + uids: [ 'chicken' ], + blocks, + } ); + } ); + } ); + } ); + + describe( 'selectors', () => { + beforeEach( () => { + getBlock.clear(); + getBlocks.clear(); + getEditedPostContent.clear(); + getDirtyMetaBoxes.clear(); + } ); + + describe( 'getPostEdits', () => { + it( 'should return the post edits', () => { + const state = { + editor: { + present: { + edits: { title: 'terga' }, + }, + }, + }; + + expect( getPostEdits( state ) ).toEqual( { title: 'terga' } ); + } ); + } ); + + describe( 'isEditedPostDirty', () => { + const metaBoxes = { + normal: { + isActive: false, + isDirty: false, + isUpdating: false, + }, + side: { + isActive: false, + isDirty: false, + isUpdating: false, + }, + }; + // Those dirty dang meta boxes. + const dirtyMetaBoxes = { + normal: { + isActive: true, + isDirty: true, + isUpdating: false, + }, + side: { + isActive: false, + isDirty: false, + isUpdating: false, + }, + }; + + it( 'should return true when post saved state dirty', () => { + const state = { + editor: { + isDirty: true, + }, + metaBoxes, + }; + + expect( isEditedPostDirty( state ) ).toBe( true ); + } ); + + it( 'should return false when post saved state not dirty', () => { + const state = { + editor: { + isDirty: false, + }, + metaBoxes, + }; + + expect( isEditedPostDirty( state ) ).toBe( false ); + } ); + + it( 'should return true when post saved state not dirty, but meta box state has changed.', () => { + const state = { + editor: { + isDirty: false, + }, + metaBoxes: dirtyMetaBoxes, + }; + + expect( isEditedPostDirty( state ) ).toBe( true ); + } ); + } ); + + describe( 'isCleanNewPost', () => { + const metaBoxes = { isDirty: false, isUpdating: false }; + + it( 'should return true when the post is not dirty and has not been saved before', () => { + const state = { + editor: { + isDirty: false, + }, + currentPost: { + id: 1, + status: 'auto-draft', + }, + metaBoxes, + }; + + expect( isCleanNewPost( state ) ).toBe( true ); + } ); + + it( 'should return false when the post is not dirty but the post has been saved', () => { + const state = { + editor: { + isDirty: false, + }, + currentPost: { + id: 1, + status: 'draft', + }, + metaBoxes, + }; + + expect( isCleanNewPost( state ) ).toBe( false ); + } ); + + it( 'should return false when the post is dirty but the post has not been saved', () => { + const state = { + editor: { + isDirty: true, + }, + currentPost: { + id: 1, + status: 'auto-draft', + }, + metaBoxes, + }; + + expect( isCleanNewPost( state ) ).toBe( false ); + } ); + } ); + + describe( 'isEditedPostPublishable', () => { + const metaBoxes = { isDirty: false, isUpdating: false }; + + it( 'should return true for pending posts', () => { + const state = { + editor: { + isDirty: false, + }, + currentPost: { + status: 'pending', + }, + metaBoxes, + }; + + expect( isEditedPostPublishable( state ) ).toBe( true ); + } ); + + it( 'should return true for draft posts', () => { + const state = { + editor: { + isDirty: false, + }, + currentPost: { + status: 'draft', + }, + metaBoxes, + }; + + expect( isEditedPostPublishable( state ) ).toBe( true ); + } ); + + it( 'should return false for published posts', () => { + const state = { + editor: { + isDirty: false, + }, + currentPost: { + status: 'publish', + }, + metaBoxes, + }; + + expect( isEditedPostPublishable( state ) ).toBe( false ); + } ); + + it( 'should return true for published, dirty posts', () => { + const state = { + editor: { + isDirty: true, + }, + currentPost: { + status: 'publish', + }, + metaBoxes, + }; + + expect( isEditedPostPublishable( state ) ).toBe( true ); + } ); + + it( 'should return false for private posts', () => { + const state = { + editor: { + isDirty: false, + }, + currentPost: { + status: 'private', + }, + metaBoxes, + }; + + expect( isEditedPostPublishable( state ) ).toBe( false ); + } ); + + it( 'should return false for scheduled posts', () => { + const state = { + editor: { + isDirty: false, + }, + currentPost: { + status: 'future', + }, + metaBoxes, + }; + + expect( isEditedPostPublishable( state ) ).toBe( false ); + } ); + + it( 'should return true for dirty posts with usable title', () => { + const state = { + currentPost: { + status: 'private', + }, + editor: { + isDirty: true, + }, + metaBoxes, + }; + + expect( isEditedPostPublishable( state ) ).toBe( true ); + } ); + } ); + + describe( 'isEditedPostSaveable', () => { + it( 'should return false if the post has no title, excerpt, content', () => { + const state = { + editor: { + present: { + blocksByUid: {}, + blockOrder: [], + edits: {}, + }, + }, + currentPost: {}, + }; + + expect( isEditedPostSaveable( state ) ).toBe( false ); + } ); + + it( 'should return true if the post has a title', () => { + const state = { + editor: { + present: { + blocksByUid: {}, + blockOrder: [], + edits: {}, + }, + }, + currentPost: { + title: 'sassel', + }, + }; + + expect( isEditedPostSaveable( state ) ).toBe( true ); + } ); + + it( 'should return true if the post has an excerpt', () => { + const state = { + editor: { + present: { + blocksByUid: {}, + blockOrder: [], + edits: {}, + }, + }, + currentPost: { + excerpt: 'sassel', + }, + }; + + expect( isEditedPostSaveable( state ) ).toBe( true ); + } ); + + it( 'should return true if the post has content', () => { + const state = { + editor: { + present: { + blocksByUid: { + 123: { + uid: 123, + name: 'core/test-block', + attributes: { + text: '', + }, + }, + }, + blockOrder: [ 123 ], + edits: {}, + }, + }, + currentPost: {}, + }; + + expect( isEditedPostSaveable( state ) ).toBe( true ); + } ); + } ); + + describe( 'getEditedPostTitle', () => { + it( 'should return the post saved title if the title is not edited', () => { + const state = { + currentPost: { + title: 'sassel', + }, + editor: { + present: { + edits: { status: 'private' }, + }, + }, + }; + + expect( getEditedPostTitle( state ) ).toBe( 'sassel' ); + } ); + + it( 'should return the edited title', () => { + const state = { + currentPost: { + title: 'sassel', + }, + editor: { + present: { + edits: { title: 'youcha' }, + }, + }, + }; + + expect( getEditedPostTitle( state ) ).toBe( 'youcha' ); + } ); + } ); + + describe( 'isEditedPostBeingScheduled', () => { + it( 'should return true for posts with a future date', () => { + const state = { + editor: { + present: { + edits: { date: moment().add( 7, 'days' ).format( '' ) }, + }, + }, + }; + + expect( isEditedPostBeingScheduled( state ) ).toBe( true ); + } ); + + it( 'should return false for posts with an old date', () => { + const state = { + editor: { + present: { + edits: { date: '2016-05-30T17:21:39' }, + }, + }, + }; + + expect( isEditedPostBeingScheduled( state ) ).toBe( false ); + } ); + } ); + + describe( 'getBlock', () => { + it( 'should return the block', () => { + const state = { + currentPost: {}, + editor: { + present: { + blocksByUid: { + 123: { uid: 123, name: 'core/paragraph' }, + }, + edits: {}, + }, + }, + }; + + expect( getBlock( state, 123 ) ).toEqual( { uid: 123, name: 'core/paragraph' } ); + } ); + + it( 'should return null if the block is not present in state', () => { + const state = { + currentPost: {}, + editor: { + present: { + blocksByUid: {}, + edits: {}, + }, + }, + }; + + expect( getBlock( state, 123 ) ).toBe( null ); + } ); + + it( 'should merge meta attributes for the block', () => { + registerBlockType( 'core/meta-block', { + save: ( props ) => props.attributes.text, + category: 'common', + title: 'test block', + attributes: { + foo: { + type: 'string', + source: 'meta', + meta: 'foo', + }, + }, + } ); + + const state = { + currentPost: { + meta: { + foo: 'bar', + }, + }, + editor: { + present: { + blocksByUid: { + 123: { uid: 123, name: 'core/meta-block' }, + }, + edits: {}, + }, + }, + }; + + expect( getBlock( state, 123 ) ).toEqual( { + uid: 123, + name: 'core/meta-block', + attributes: { + foo: 'bar', + }, + } ); + } ); + } ); + + describe( 'getEditedPostExcerpt', () => { + it( 'should return the post saved excerpt if the excerpt is not edited', () => { + const state = { + currentPost: { + excerpt: 'sassel', + }, + editor: { + present: { + edits: { status: 'private' }, + }, + }, + }; + + expect( getEditedPostExcerpt( state ) ).toBe( 'sassel' ); + } ); + + it( 'should return the edited excerpt', () => { + const state = { + currentPost: { + excerpt: 'sassel', + }, + editor: { + present: { + edits: { excerpt: 'youcha' }, + }, + }, + }; + + expect( getEditedPostExcerpt( state ) ).toBe( 'youcha' ); + } ); + } ); + + describe( 'getEditedPostVisibility', () => { + it( 'should return public by default', () => { + const state = { + currentPost: { + status: 'draft', + }, + editor: { + present: { + edits: {}, + }, + }, + }; + + expect( getEditedPostVisibility( state ) ).toBe( 'public' ); + } ); + + it( 'should return private for private posts', () => { + const state = { + currentPost: { + status: 'private', + }, + editor: { + present: { + edits: {}, + }, + }, + }; + + expect( getEditedPostVisibility( state ) ).toBe( 'private' ); + } ); + + it( 'should return private for password for password protected posts', () => { + const state = { + currentPost: { + status: 'draft', + password: 'chicken', + }, + editor: { + present: { + edits: {}, + }, + }, + }; + + expect( getEditedPostVisibility( state ) ).toBe( 'password' ); + } ); + + it( 'should use the edited status and password if edits present', () => { + const state = { + currentPost: { + status: 'draft', + password: 'chicken', + }, + editor: { + present: { + edits: { + status: 'private', + password: null, + }, + }, + }, + }; + + expect( getEditedPostVisibility( state ) ).toBe( 'private' ); + } ); + } ); + + describe( 'getBlocks', () => { + it( 'should return the ordered blocks', () => { + const state = { + currentPost: {}, + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], + edits: {}, + }, + }, + }; + + expect( getBlocks( state ) ).toEqual( [ + { uid: 123, name: 'core/paragraph' }, + { uid: 23, name: 'core/heading' }, + ] ); + } ); + } ); + + describe( 'getBlockCount', () => { + it( 'should return the number of blocks in the post', () => { + const state = { + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], + }, + }, + }; + + expect( getBlockCount( state ) ).toBe( 2 ); + } ); + } ); + + describe( 'getBlockUids', () => { + it( 'should return the ordered block UIDs', () => { + const state = { + editor: { + present: { + blockOrder: [ 123, 23 ], + }, + }, + }; + + expect( getBlockUids( state ) ).toEqual( [ 123, 23 ] ); + } ); + } ); + + describe( 'getBlockIndex', () => { + it( 'should return the block order', () => { + const state = { + editor: { + present: { + blockOrder: [ 123, 23 ], + }, + }, + }; + + expect( getBlockIndex( state, 23 ) ).toBe( 1 ); + } ); + } ); + + describe( 'isFirstBlock', () => { + it( 'should return true when the block is first', () => { + const state = { + editor: { + present: { + blockOrder: [ 123, 23 ], + }, + }, + }; + + expect( isFirstBlock( state, 123 ) ).toBe( true ); + } ); + + it( 'should return false when the block is not first', () => { + const state = { + editor: { + present: { + blockOrder: [ 123, 23 ], + }, + }, + }; + + expect( isFirstBlock( state, 23 ) ).toBe( false ); + } ); + } ); + + describe( 'isLastBlock', () => { + it( 'should return true when the block is last', () => { + const state = { + editor: { + present: { + blockOrder: [ 123, 23 ], + }, + }, + }; + + expect( isLastBlock( state, 23 ) ).toBe( true ); + } ); + + it( 'should return false when the block is not last', () => { + const state = { + editor: { + present: { + blockOrder: [ 123, 23 ], + }, + }, + }; + + expect( isLastBlock( state, 123 ) ).toBe( false ); + } ); + } ); + + describe( 'getPreviousBlock', () => { + it( 'should return the previous block', () => { + const state = { + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], + }, + }, + }; + + expect( getPreviousBlock( state, 23 ) ).toEqual( { uid: 123, name: 'core/paragraph' } ); + } ); + + it( 'should return null for the first block', () => { + const state = { + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], + }, + }, + }; + + expect( getPreviousBlock( state, 123 ) ).toBeNull(); + } ); + } ); + + describe( 'getNextBlock', () => { + it( 'should return the following block', () => { + const state = { + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], + }, + }, + }; + + expect( getNextBlock( state, 123 ) ).toEqual( { uid: 23, name: 'core/heading' } ); + } ); + + it( 'should return null for the last block', () => { + const state = { + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], + }, + }, + }; + + expect( getNextBlock( state, 23 ) ).toBeNull(); + } ); + } ); + + describe( 'getSuggestedPostFormat', () => { + it( 'returns null if cannot be determined', () => { + const state = { + editor: { + present: { + blockOrder: [], + blocksByUid: {}, + }, + }, + }; + + expect( getSuggestedPostFormat( state ) ).toBeNull(); + } ); + + it( 'returns null if there is more than one block in the post', () => { + const state = { + editor: { + present: { + blockOrder: [ 123, 456 ], + blocksByUid: { + 123: { uid: 123, name: 'core/image' }, + 456: { uid: 456, name: 'core/quote' }, + }, + }, + }, + }; + + expect( getSuggestedPostFormat( state ) ).toBeNull(); + } ); + + it( 'returns Image if the first block is of type `core/image`', () => { + const state = { + editor: { + present: { + blockOrder: [ 123 ], + blocksByUid: { + 123: { uid: 123, name: 'core/image' }, + }, + }, + }, + }; + + expect( getSuggestedPostFormat( state ) ).toBe( 'image' ); + } ); + + it( 'returns Quote if the first block is of type `core/quote`', () => { + const state = { + editor: { + present: { + blockOrder: [ 456 ], + blocksByUid: { + 456: { uid: 456, name: 'core/quote' }, + }, + }, + }, + }; + + expect( getSuggestedPostFormat( state ) ).toBe( 'quote' ); + } ); + + it( 'returns Video if the first block is of type `core-embed/youtube`', () => { + const state = { + editor: { + present: { + blockOrder: [ 567 ], + blocksByUid: { + 567: { uid: 567, name: 'core-embed/youtube' }, + }, + }, + }, + }; + + expect( getSuggestedPostFormat( state ) ).toBe( 'video' ); + } ); + + it( 'returns Quote if the first block is of type `core/quote` and second is of type `core/paragraph`', () => { + const state = { + editor: { + present: { + blockOrder: [ 456, 789 ], + blocksByUid: { + 456: { uid: 456, name: 'core/quote' }, + 789: { uid: 789, name: 'core/paragraph' }, + }, + }, + }, + }; + + expect( getSuggestedPostFormat( state ) ).toBe( 'quote' ); + } ); + } ); + + describe( 'hasEditorUndo', () => { + it( 'should return true when the past history is not empty', () => { + const state = { + editor: { + past: [ + {}, + ], + }, + }; + + expect( hasEditorUndo( state ) ).toBe( true ); + } ); + + it( 'should return false when the past history is empty', () => { + const state = { + editor: { + past: [], + }, + }; + + expect( hasEditorUndo( state ) ).toBe( false ); + } ); + } ); + + describe( 'hasEditorRedo', () => { + it( 'should return true when the future history is not empty', () => { + const state = { + editor: { + future: [ + {}, + ], + }, + }; + + expect( hasEditorRedo( state ) ).toBe( true ); + } ); + + it( 'should return false when the future history is empty', () => { + const state = { + editor: { + future: [], + }, + }; + + expect( hasEditorRedo( state ) ).toBe( false ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/effects.js b/editor/state/test/effects.js index fbc67a387fe36a..6ac043900852b0 100644 --- a/editor/state/test/effects.js +++ b/editor/state/test/effects.js @@ -187,7 +187,7 @@ describe( 'effects', () => { selectors.isEditedPostSaveable.mockReturnValue( false ); selectors.isEditedPostDirty.mockReturnValue( true ); selectors.isCurrentPostPublished.mockReturnValue( false ); - selectors.isEditedPostNew.mockReturnValue( true ); + selectors.isCurrentPostNew.mockReturnValue( true ); expect( dispatch ).not.toHaveBeenCalled(); } ); @@ -196,7 +196,7 @@ describe( 'effects', () => { selectors.isEditedPostSaveable.mockReturnValue( true ); selectors.isEditedPostDirty.mockReturnValue( false ); selectors.isCurrentPostPublished.mockReturnValue( false ); - selectors.isEditedPostNew.mockReturnValue( false ); + selectors.isCurrentPostNew.mockReturnValue( false ); expect( dispatch ).not.toHaveBeenCalled(); } ); @@ -205,7 +205,7 @@ describe( 'effects', () => { selectors.isEditedPostSaveable.mockReturnValue( true ); selectors.isEditedPostDirty.mockReturnValue( false ); selectors.isCurrentPostPublished.mockReturnValue( false ); - selectors.isEditedPostNew.mockReturnValue( true ); + selectors.isCurrentPostNew.mockReturnValue( true ); handler( {}, store ); @@ -218,7 +218,7 @@ describe( 'effects', () => { selectors.isEditedPostSaveable.mockReturnValue( true ); selectors.isEditedPostDirty.mockReturnValue( true ); selectors.isCurrentPostPublished.mockReturnValue( true ); - selectors.isEditedPostNew.mockReturnValue( true ); + selectors.isCurrentPostNew.mockReturnValue( true ); // TODO: Publish autosave expect( dispatch ).not.toHaveBeenCalled(); @@ -228,7 +228,7 @@ describe( 'effects', () => { selectors.isEditedPostSaveable.mockReturnValue( true ); selectors.isEditedPostDirty.mockReturnValue( true ); selectors.isCurrentPostPublished.mockReturnValue( false ); - selectors.isEditedPostNew.mockReturnValue( true ); + selectors.isCurrentPostNew.mockReturnValue( true ); handler( {}, store ); @@ -241,7 +241,7 @@ describe( 'effects', () => { selectors.isEditedPostSaveable.mockReturnValue( true ); selectors.isEditedPostDirty.mockReturnValue( true ); selectors.isCurrentPostPublished.mockReturnValue( false ); - selectors.isEditedPostNew.mockReturnValue( false ); + selectors.isCurrentPostNew.mockReturnValue( false ); handler( {}, store ); diff --git a/editor/state/test/hovered-block.js b/editor/state/test/hovered-block.js new file mode 100644 index 00000000000000..8fb84a32fab773 --- /dev/null +++ b/editor/state/test/hovered-block.js @@ -0,0 +1,73 @@ +/** + * Internal dependencies + */ +import reducer, { isBlockHovered } from '../hovered-block'; + +describe( 'hoveredBlock', () => { + describe( 'reducer', () => { + it( 'should return with block uid as hovered', () => { + const state = reducer( null, { + type: 'TOGGLE_BLOCK_HOVERED', + uid: 'kumquat', + hovered: true, + } ); + + expect( state ).toBe( 'kumquat' ); + } ); + + it( 'should return null when a block is selected', () => { + const state = reducer( 'kumquat', { + type: 'SELECT_BLOCK', + uid: 'kumquat', + } ); + + expect( state ).toBeNull(); + } ); + + it( 'should replace the hovered block', () => { + const state = reducer( 'chicken', { + type: 'REPLACE_BLOCKS', + uids: [ 'chicken' ], + blocks: [ { + uid: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toBe( 'wings' ); + } ); + + it( 'should keep the hovered block', () => { + const state = reducer( 'chicken', { + type: 'REPLACE_BLOCKS', + uids: [ 'ribs' ], + blocks: [ { + uid: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toBe( 'chicken' ); + } ); + } ); + + describe( 'selectors', () => { + describe( 'isBlockHovered', () => { + it( 'should return true if the block is hovered', () => { + const state = { + hoveredBlock: 123, + }; + + expect( isBlockHovered( state, 123 ) ).toBe( true ); + } ); + + it( 'should return false if the block is not hovered', () => { + const state = { + hoveredBlock: 123, + }; + + expect( isBlockHovered( state, 23 ) ).toBe( false ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/is-typing.js b/editor/state/test/is-typing.js new file mode 100644 index 00000000000000..ec64ca666bb4ca --- /dev/null +++ b/editor/state/test/is-typing.js @@ -0,0 +1,66 @@ +/** + * Internal dependencies + */ +import reducer, { + startTyping, + stopTyping, + isTyping, +} from '../is-typing'; + +describe( 'isTyping', () => { + describe( 'reducer', () => { + it( 'should set the typing flag to true', () => { + const state = reducer( false, { + type: 'START_TYPING', + } ); + + expect( state ).toBe( true ); + } ); + + it( 'should set the typing flag to false', () => { + const state = reducer( false, { + type: 'STOP_TYPING', + } ); + + expect( state ).toBe( false ); + } ); + } ); + + describe( 'action creators', () => { + describe( 'startTyping', () => { + it( 'should return the START_TYPING action', () => { + expect( startTyping() ).toEqual( { + type: 'START_TYPING', + } ); + } ); + } ); + + describe( 'stopTyping', () => { + it( 'should return the STOP_TYPING action', () => { + expect( stopTyping() ).toEqual( { + type: 'STOP_TYPING', + } ); + } ); + } ); + } ); + + describe( 'selectors', () => { + describe( 'isTyping', () => { + it( 'should return the isTyping flag if the block is selected', () => { + const state = { + isTyping: true, + }; + + expect( isTyping( state ) ).toBe( true ); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + isTyping: false, + }; + + expect( isTyping( state ) ).toBe( false ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/meta-boxes.js b/editor/state/test/meta-boxes.js new file mode 100644 index 00000000000000..0e6ca632f7e28a --- /dev/null +++ b/editor/state/test/meta-boxes.js @@ -0,0 +1,339 @@ +/** + * Internal dependencies + */ +import reducer, { + requestMetaBoxUpdates, + handleMetaBoxReload, + metaBoxStateChanged, + initializeMetaBoxState, + getDirtyMetaBoxes, + getMetaBoxes, + getMetaBox, + isMetaBoxStateDirty, +} from '../meta-boxes'; + +describe( 'metaBoxes', () => { + describe( 'reducer', () => { + it( 'should return default state', () => { + const actual = reducer( undefined, {} ); + const expected = { + normal: { + isActive: false, + isDirty: false, + isUpdating: false, + }, + side: { + isActive: false, + isDirty: false, + isUpdating: false, + }, + }; + + expect( actual ).toEqual( expected ); + } ); + + it( 'should set the sidebar to active', () => { + const theMetaBoxes = { + normal: false, + advanced: false, + side: true, + }; + + const action = { + type: 'INITIALIZE_META_BOX_STATE', + metaBoxes: theMetaBoxes, + }; + + const actual = reducer( undefined, action ); + const expected = { + normal: { + isActive: false, + isDirty: false, + isUpdating: false, + isLoaded: false, + }, + side: { + isActive: true, + isDirty: false, + isUpdating: false, + isLoaded: false, + }, + }; + + expect( actual ).toEqual( expected ); + } ); + + it( 'should switch updating to off', () => { + const action = { + type: 'HANDLE_META_BOX_RELOAD', + location: 'normal', + }; + + const theMetaBoxes = reducer( { normal: { isUpdating: true, isActive: false, isDirty: true } }, action ); + const actual = theMetaBoxes.normal; + const expected = { + isActive: false, + isUpdating: false, + isDirty: false, + }; + + expect( actual ).toEqual( expected ); + } ); + + it( 'should switch updating to on', () => { + const action = { + type: 'REQUEST_META_BOX_UPDATES', + locations: [ 'normal' ], + }; + + const theMetaBoxes = reducer( undefined, action ); + const actual = theMetaBoxes.normal; + const expected = { + isActive: false, + isUpdating: true, + isDirty: false, + }; + + expect( actual ).toEqual( expected ); + } ); + + it( 'should return with the isDirty flag as true', () => { + const action = { + type: 'META_BOX_STATE_CHANGED', + location: 'normal', + hasChanged: true, + }; + const theMetaBoxes = reducer( undefined, action ); + const actual = theMetaBoxes.normal; + const expected = { + isActive: false, + isDirty: true, + isUpdating: false, + }; + + expect( actual ).toEqual( expected ); + } ); + } ); + + describe( 'action creators', () => { + describe( 'requestMetaBoxUpdates', () => { + it( 'should return the REQUEST_META_BOX_UPDATES action', () => { + expect( requestMetaBoxUpdates( [ 'normal' ] ) ).toEqual( { + type: 'REQUEST_META_BOX_UPDATES', + locations: [ 'normal' ], + } ); + } ); + } ); + + describe( 'handleMetaBoxReload', () => { + it( 'should return the HANDLE_META_BOX_RELOAD action with a location and node', () => { + expect( handleMetaBoxReload( 'normal' ) ).toEqual( { + type: 'HANDLE_META_BOX_RELOAD', + location: 'normal', + } ); + } ); + } ); + + describe( 'metaBoxStateChanged', () => { + it( 'should return the META_BOX_STATE_CHANGED action with a hasChanged flag', () => { + expect( metaBoxStateChanged( 'normal', true ) ).toEqual( { + type: 'META_BOX_STATE_CHANGED', + location: 'normal', + hasChanged: true, + } ); + } ); + } ); + + describe( 'initializeMetaBoxState', () => { + it( 'should return the META_BOX_STATE_CHANGED action with a hasChanged flag', () => { + const metaBoxes = { + side: true, + normal: true, + advanced: false, + }; + + expect( initializeMetaBoxState( metaBoxes ) ).toEqual( { + type: 'INITIALIZE_META_BOX_STATE', + metaBoxes, + } ); + } ); + } ); + } ); + + describe( 'selectors', () => { + beforeEach( () => { + getDirtyMetaBoxes.clear(); + } ); + + describe( 'getDirtyMetaBoxes', () => { + it( 'should return array of just the side location', () => { + const state = { + metaBoxes: { + normal: { + isActive: false, + isDirty: false, + isUpdating: false, + }, + side: { + isActive: true, + isDirty: true, + isUpdating: false, + }, + }, + }; + + expect( getDirtyMetaBoxes( state ) ).toEqual( [ 'side' ] ); + } ); + } ); + + describe( 'getMetaBoxes', () => { + it( 'should return the state of all meta boxes', () => { + const state = { + metaBoxes: { + normal: { + isDirty: false, + isUpdating: false, + }, + side: { + isDirty: false, + isUpdating: false, + }, + }, + }; + + expect( getMetaBoxes( state ) ).toEqual( { + normal: { + isDirty: false, + isUpdating: false, + }, + side: { + isDirty: false, + isUpdating: false, + }, + } ); + } ); + } ); + + describe( 'getMetaBox', () => { + it( 'should return the state of selected meta box', () => { + const state = { + metaBoxes: { + normal: { + isActive: false, + isDirty: false, + isUpdating: false, + }, + side: { + isActive: true, + isDirty: false, + isUpdating: false, + }, + }, + }; + + expect( getMetaBox( state, 'side' ) ).toEqual( { + isActive: true, + isDirty: false, + isUpdating: false, + } ); + } ); + } ); + + describe( 'isMetaBoxStateDirty', () => { + it( 'should return false', () => { + const state = { + metaBoxes: { + normal: { + isActive: false, + isDirty: false, + isUpdating: false, + }, + side: { + isActive: false, + isDirty: false, + isUpdating: false, + }, + }, + }; + + expect( isMetaBoxStateDirty( state ) ).toEqual( false ); + } ); + + it( 'should return false when a dirty meta box is not active.', () => { + const state = { + metaBoxes: { + normal: { + isActive: false, + isDirty: true, + isUpdating: false, + }, + side: { + isActive: false, + isDirty: false, + isUpdating: false, + }, + }, + }; + + expect( isMetaBoxStateDirty( state ) ).toEqual( false ); + } ); + + it( 'should return false when both meta boxes are dirty but inactive.', () => { + const state = { + metaBoxes: { + normal: { + isActive: false, + isDirty: true, + isUpdating: false, + }, + side: { + isActive: false, + isDirty: true, + isUpdating: false, + }, + }, + }; + + expect( isMetaBoxStateDirty( state ) ).toEqual( false ); + } ); + + it( 'should return false when a dirty meta box is active.', () => { + const state = { + metaBoxes: { + normal: { + isActive: true, + isDirty: true, + isUpdating: false, + }, + side: { + isActive: false, + isDirty: false, + isUpdating: false, + }, + }, + }; + + expect( isMetaBoxStateDirty( state ) ).toEqual( true ); + } ); + + it( 'should return false when both meta boxes are dirty and active.', () => { + const state = { + metaBoxes: { + normal: { + isActive: true, + isDirty: true, + isUpdating: false, + }, + side: { + isActive: true, + isDirty: true, + isUpdating: false, + }, + }, + }; + + expect( isMetaBoxStateDirty( state ) ).toEqual( true ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/notices.js b/editor/state/test/notices.js new file mode 100644 index 00000000000000..a023fef27533fe --- /dev/null +++ b/editor/state/test/notices.js @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import reducer, { getNotices } from '../notices'; + +describe( 'notices', () => { + describe( 'reducer', () => { + it( 'should create a notice', () => { + const originalState = [ + { + id: 'b', + content: 'Error saving', + status: 'error', + }, + ]; + const state = reducer( deepFreeze( originalState ), { + type: 'CREATE_NOTICE', + notice: { + id: 'a', + content: 'Post saved', + status: 'success', + }, + } ); + expect( state ).toEqual( [ + originalState[ 0 ], + { + id: 'a', + content: 'Post saved', + status: 'success', + }, + ] ); + } ); + + it( 'should remove a notice', () => { + const originalState = [ + { + id: 'a', + content: 'Post saved', + status: 'success', + }, + { + id: 'b', + content: 'Error saving', + status: 'error', + }, + ]; + const state = reducer( deepFreeze( originalState ), { + type: 'REMOVE_NOTICE', + noticeId: 'a', + } ); + expect( state ).toEqual( [ + originalState[ 1 ], + ] ); + } ); + } ); + + describe( 'getNotices', () => { + it( 'should return the notices array', () => { + const state = { + notices: [ + { id: 'b', content: 'Post saved' }, + { id: 'a', content: 'Error saving' }, + ], + }; + + expect( getNotices( state ) ).toEqual( state.notices ); + } ); + } ); +} ); diff --git a/editor/state/test/preferences.js b/editor/state/test/preferences.js new file mode 100644 index 00000000000000..276acbd1fec54f --- /dev/null +++ b/editor/state/test/preferences.js @@ -0,0 +1,289 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * WordPress dependencies + */ +import { getBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import reducer, { + getPreference, + isEditorSidebarOpened, + isEditorSidebarPanelOpened, + getMostFrequentlyUsedBlocks, + getRecentlyUsedBlocks, + getEditorMode, +} from '../preferences'; + +describe( 'preferences', () => { + describe( 'reducer', () => { + it( 'should apply all defaults', () => { + const state = reducer( undefined, {} ); + + expect( state ).toEqual( { + blockUsage: {}, + recentlyUsedBlocks: [], + mode: 'visual', + isSidebarOpened: true, + panels: { 'post-status': true }, + features: { fixedToolbar: true }, + } ); + } ); + + it( 'should toggle the sidebar open flag', () => { + const state = reducer( deepFreeze( { isSidebarOpened: false } ), { + type: 'TOGGLE_SIDEBAR', + } ); + + expect( state ).toEqual( { isSidebarOpened: true } ); + } ); + + it( 'should set the sidebar panel open flag to true if unset', () => { + const state = reducer( deepFreeze( { isSidebarOpened: false } ), { + type: 'TOGGLE_SIDEBAR_PANEL', + panel: 'post-taxonomies', + } ); + + expect( state ).toEqual( { isSidebarOpened: false, panels: { 'post-taxonomies': true } } ); + } ); + + it( 'should toggle the sidebar panel open flag', () => { + const state = reducer( deepFreeze( { isSidebarOpened: false, panels: { 'post-taxonomies': true } } ), { + type: 'TOGGLE_SIDEBAR_PANEL', + panel: 'post-taxonomies', + } ); + + expect( state ).toEqual( { isSidebarOpened: false, panels: { 'post-taxonomies': false } } ); + } ); + + it( 'should return switched mode', () => { + const state = reducer( deepFreeze( { isSidebarOpened: false } ), { + type: 'SWITCH_MODE', + mode: 'text', + } ); + + expect( state ).toEqual( { isSidebarOpened: false, mode: 'text' } ); + } ); + + it( 'should record recently used blocks', () => { + const state = reducer( deepFreeze( { recentlyUsedBlocks: [], blockUsage: {} } ), { + type: 'INSERT_BLOCKS', + blocks: [ { + uid: 'bacon', + name: 'core-embed/twitter', + } ], + } ); + + expect( state.recentlyUsedBlocks[ 0 ] ).toEqual( 'core-embed/twitter' ); + + const twoRecentBlocks = reducer( deepFreeze( { recentlyUsedBlocks: [], blockUsage: {} } ), { + type: 'INSERT_BLOCKS', + blocks: [ { + uid: 'eggs', + name: 'core-embed/twitter', + }, { + uid: 'bacon', + name: 'core-embed/youtube', + } ], + } ); + + expect( twoRecentBlocks.recentlyUsedBlocks[ 0 ] ).toEqual( 'core-embed/youtube' ); + expect( twoRecentBlocks.recentlyUsedBlocks[ 1 ] ).toEqual( 'core-embed/twitter' ); + } ); + + it( 'should record block usage', () => { + const state = reducer( deepFreeze( { recentlyUsedBlocks: [], blockUsage: {} } ), { + type: 'INSERT_BLOCKS', + blocks: [ { + uid: 'eggs', + name: 'core-embed/twitter', + }, { + uid: 'bacon', + name: 'core-embed/youtube', + }, { + uid: 'milk', + name: 'core-embed/youtube', + } ], + } ); + + expect( state.blockUsage ).toEqual( { 'core-embed/youtube': 2, 'core-embed/twitter': 1 } ); + } ); + + it( 'should populate recentlyUsedBlocks, filling up with common blocks, on editor setup', () => { + const state = reducer( deepFreeze( { recentlyUsedBlocks: [ 'core-embed/twitter', 'core-embed/youtube' ] } ), { + type: 'SETUP_EDITOR', + } ); + + expect( state.recentlyUsedBlocks[ 0 ] ).toEqual( 'core-embed/twitter' ); + expect( state.recentlyUsedBlocks[ 1 ] ).toEqual( 'core-embed/youtube' ); + + state.recentlyUsedBlocks.slice( 2 ).forEach( + block => expect( getBlockType( block ).category ).toEqual( 'common' ) + ); + expect( state.recentlyUsedBlocks ).toHaveLength( 8 ); + } ); + + it( 'should remove unregistered blocks from persisted recent usage', () => { + const state = reducer( deepFreeze( { recentlyUsedBlocks: [ 'core-embed/i-do-not-exist', 'core-embed/youtube' ] } ), { + type: 'SETUP_EDITOR', + } ); + expect( state.recentlyUsedBlocks[ 0 ] ).toEqual( 'core-embed/youtube' ); + } ); + + it( 'should remove unregistered blocks from persisted block usage stats', () => { + const state = reducer( deepFreeze( { recentlyUsedBlocks: [], blockUsage: { 'core/i-do-not-exist': 42, 'core-embed/youtube': 88 } } ), { + type: 'SETUP_EDITOR', + } ); + expect( state.blockUsage ).toEqual( { 'core-embed/youtube': 88 } ); + } ); + + it( 'should toggle a feature flag', () => { + const state = reducer( deepFreeze( { features: { chicken: true } } ), { + type: 'TOGGLE_FEATURE', + feature: 'chicken', + } ); + expect( state ).toEqual( { features: { chicken: false } } ); + } ); + } ); + + describe( 'selectors', () => { + beforeEach( () => { + getMostFrequentlyUsedBlocks.clear(); + } ); + + describe( 'getPreference()', () => { + it( 'should return the preference value if set', () => { + const state = { + preferences: { chicken: true }, + }; + + expect( getPreference( state, 'chicken' ) ).toBe( true ); + } ); + + it( 'should return undefined if the preference is unset', () => { + const state = { + preferences: { chicken: true }, + }; + + expect( getPreference( state, 'ribs' ) ).toBeUndefined(); + } ); + + it( 'should return the default value if provided', () => { + const state = { + preferences: {}, + }; + + expect( getPreference( state, 'ribs', 'chicken' ) ).toEqual( 'chicken' ); + } ); + } ); + + describe( 'isEditorSidebarOpened', () => { + it( 'should return true when the sidebar is opened', () => { + const state = { + preferences: { isSidebarOpened: true }, + }; + + expect( isEditorSidebarOpened( state ) ).toBe( true ); + } ); + + it( 'should return false when the sidebar is opened', () => { + const state = { + preferences: { isSidebarOpened: false }, + }; + + expect( isEditorSidebarOpened( state ) ).toBe( false ); + } ); + } ); + + describe( 'isEditorSidebarPanelOpened', () => { + it( 'should return false if no panels preference', () => { + const state = { + preferences: { isSidebarOpened: true }, + }; + + expect( isEditorSidebarPanelOpened( state, 'post-taxonomies' ) ).toBe( false ); + } ); + + it( 'should return false if the panel value is not set', () => { + const state = { + preferences: { panels: {} }, + }; + + expect( isEditorSidebarPanelOpened( state, 'post-taxonomies' ) ).toBe( false ); + } ); + + it( 'should return the panel value', () => { + const state = { + preferences: { panels: { 'post-taxonomies': true } }, + }; + + expect( isEditorSidebarPanelOpened( state, 'post-taxonomies' ) ).toBe( true ); + } ); + } ); + + describe( 'getMostFrequentlyUsedBlocks', () => { + it( 'should have paragraph and image to bring frequently used blocks up to three blocks', () => { + const noUsage = { preferences: { blockUsage: {} } }; + const someUsage = { preferences: { blockUsage: { 'core/paragraph': 1 } } }; + + expect( getMostFrequentlyUsedBlocks( noUsage ).map( ( block ) => block.name ) ) + .toEqual( [ 'core/paragraph', 'core/image' ] ); + + expect( getMostFrequentlyUsedBlocks( someUsage ).map( ( block ) => block.name ) ) + .toEqual( [ 'core/paragraph', 'core/image' ] ); + } ); + it( 'should return the top 3 most recently used blocks', () => { + const state = { + preferences: { + blockUsage: { + 'core/deleted-block': 20, + 'core/paragraph': 4, + 'core/image': 11, + 'core/quote': 2, + 'core/gallery': 1, + }, + }, + }; + + expect( getMostFrequentlyUsedBlocks( state ).map( ( block ) => block.name ) ) + .toEqual( [ 'core/image', 'core/paragraph', 'core/quote' ] ); + } ); + } ); + + describe( 'getRecentlyUsedBlocks', () => { + it( 'should return the most recently used blocks', () => { + const state = { + preferences: { + recentlyUsedBlocks: [ 'core/deleted-block', 'core/paragraph', 'core/image' ], + }, + }; + + expect( getRecentlyUsedBlocks( state ).map( ( block ) => block.name ) ) + .toEqual( [ 'core/paragraph', 'core/image' ] ); + } ); + } ); + + describe( 'getEditorMode', () => { + it( 'should return the selected editor mode', () => { + const state = { + preferences: { mode: 'text' }, + }; + + expect( getEditorMode( state ) ).toEqual( 'text' ); + } ); + + it( 'should fallback to visual if not set', () => { + const state = { + preferences: {}, + }; + + expect( getEditorMode( state ) ).toEqual( 'visual' ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/reducer.js b/editor/state/test/reducer.js deleted file mode 100644 index 0c7317b851051c..00000000000000 --- a/editor/state/test/reducer.js +++ /dev/null @@ -1,1390 +0,0 @@ -/** - * External dependencies - */ -import { values, noop } from 'lodash'; -import deepFreeze from 'deep-freeze'; - -/** - * WordPress dependencies - */ -import { registerBlockType, unregisterBlockType, getBlockType } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { - getPostRawValue, - editor, - currentPost, - hoveredBlock, - isTyping, - blockSelection, - preferences, - saving, - notices, - blocksMode, - blockInsertionPoint, - metaBoxes, - reusableBlocks, -} from '../reducer'; - -describe( 'state', () => { - describe( 'getPostRawValue', () => { - it( 'returns original value for non-rendered content', () => { - const value = getPostRawValue( '' ); - - expect( value ).toBe( '' ); - } ); - - it( 'returns raw value for rendered content', () => { - const value = getPostRawValue( { raw: '' } ); - - expect( value ).toBe( '' ); - } ); - } ); - - describe( 'editor()', () => { - beforeAll( () => { - registerBlockType( 'core/test-block', { - save: noop, - edit: noop, - category: 'common', - title: 'test block', - } ); - } ); - - afterAll( () => { - unregisterBlockType( 'core/test-block' ); - } ); - - it( 'should return history (empty edits, blocksByUid, blockOrder), dirty flag by default', () => { - const state = editor( undefined, {} ); - - expect( state.past ).toEqual( [] ); - expect( state.future ).toEqual( [] ); - expect( state.present.edits ).toEqual( {} ); - expect( state.present.blocksByUid ).toEqual( {} ); - expect( state.present.blockOrder ).toEqual( [] ); - expect( state.isDirty ).toBe( false ); - } ); - - it( 'should key by replaced blocks uid', () => { - const original = editor( undefined, {} ); - const state = editor( original, { - type: 'RESET_BLOCKS', - blocks: [ { uid: 'bananas' } ], - } ); - - expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 1 ); - expect( values( state.present.blocksByUid )[ 0 ].uid ).toBe( 'bananas' ); - expect( state.present.blockOrder ).toEqual( [ 'bananas' ] ); - } ); - - it( 'should insert block', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'chicken', - name: 'core/test-block', - attributes: {}, - } ], - } ); - const state = editor( original, { - type: 'INSERT_BLOCKS', - blocks: [ { - uid: 'ribs', - name: 'core/freeform', - } ], - } ); - - expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 2 ); - expect( values( state.present.blocksByUid )[ 1 ].uid ).toBe( 'ribs' ); - expect( state.present.blockOrder ).toEqual( [ 'chicken', 'ribs' ] ); - } ); - - it( 'should replace the block', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'chicken', - name: 'core/test-block', - attributes: {}, - } ], - } ); - const state = editor( original, { - type: 'REPLACE_BLOCKS', - uids: [ 'chicken' ], - blocks: [ { - uid: 'wings', - name: 'core/freeform', - } ], - } ); - - expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 1 ); - expect( values( state.present.blocksByUid )[ 0 ].name ).toBe( 'core/freeform' ); - expect( values( state.present.blocksByUid )[ 0 ].uid ).toBe( 'wings' ); - expect( state.present.blockOrder ).toEqual( [ 'wings' ] ); - } ); - - it( 'should update the block', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'chicken', - name: 'core/test-block', - attributes: {}, - isValid: false, - } ], - } ); - const state = editor( deepFreeze( original ), { - type: 'UPDATE_BLOCK', - uid: 'chicken', - updates: { - attributes: { content: 'ribs' }, - isValid: true, - }, - } ); - - expect( state.present.blocksByUid.chicken ).toEqual( { - uid: 'chicken', - name: 'core/test-block', - attributes: { content: 'ribs' }, - isValid: true, - } ); - } ); - - it( 'should move the block up', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'chicken', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'ribs', - name: 'core/test-block', - attributes: {}, - } ], - } ); - const state = editor( original, { - type: 'MOVE_BLOCKS_UP', - uids: [ 'ribs' ], - } ); - - expect( state.present.blockOrder ).toEqual( [ 'ribs', 'chicken' ] ); - } ); - - it( 'should move multiple blocks up', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'chicken', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'ribs', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'veggies', - name: 'core/test-block', - attributes: {}, - } ], - } ); - const state = editor( original, { - type: 'MOVE_BLOCKS_UP', - uids: [ 'ribs', 'veggies' ], - } ); - - expect( state.present.blockOrder ).toEqual( [ 'ribs', 'veggies', 'chicken' ] ); - } ); - - it( 'should not move the first block up', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'chicken', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'ribs', - name: 'core/test-block', - attributes: {}, - } ], - } ); - const state = editor( original, { - type: 'MOVE_BLOCKS_UP', - uids: [ 'chicken' ], - } ); - - expect( state.present.blockOrder ).toBe( original.present.blockOrder ); - } ); - - it( 'should move the block down', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'chicken', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'ribs', - name: 'core/test-block', - attributes: {}, - } ], - } ); - const state = editor( original, { - type: 'MOVE_BLOCKS_DOWN', - uids: [ 'chicken' ], - } ); - - expect( state.present.blockOrder ).toEqual( [ 'ribs', 'chicken' ] ); - } ); - - it( 'should move multiple blocks down', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'chicken', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'ribs', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'veggies', - name: 'core/test-block', - attributes: {}, - } ], - } ); - const state = editor( original, { - type: 'MOVE_BLOCKS_DOWN', - uids: [ 'chicken', 'ribs' ], - } ); - - expect( state.present.blockOrder ).toEqual( [ 'veggies', 'chicken', 'ribs' ] ); - } ); - - it( 'should not move the last block down', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'chicken', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'ribs', - name: 'core/test-block', - attributes: {}, - } ], - } ); - const state = editor( original, { - type: 'MOVE_BLOCKS_DOWN', - uids: [ 'ribs' ], - } ); - - expect( state.present.blockOrder ).toBe( original.present.blockOrder ); - } ); - - it( 'should remove the block', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'chicken', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'ribs', - name: 'core/test-block', - attributes: {}, - } ], - } ); - const state = editor( original, { - type: 'REMOVE_BLOCKS', - uids: [ 'chicken' ], - } ); - - expect( state.present.blockOrder ).toEqual( [ 'ribs' ] ); - expect( state.present.blocksByUid ).toEqual( { - ribs: { - uid: 'ribs', - name: 'core/test-block', - attributes: {}, - }, - } ); - } ); - - it( 'should remove multiple blocks', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'chicken', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'ribs', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'veggies', - name: 'core/test-block', - attributes: {}, - } ], - } ); - const state = editor( original, { - type: 'REMOVE_BLOCKS', - uids: [ 'chicken', 'veggies' ], - } ); - - expect( state.present.blockOrder ).toEqual( [ 'ribs' ] ); - expect( state.present.blocksByUid ).toEqual( { - ribs: { - uid: 'ribs', - name: 'core/test-block', - attributes: {}, - }, - } ); - } ); - - it( 'should insert at the specified position', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'kumquat', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'loquat', - name: 'core/test-block', - attributes: {}, - } ], - } ); - - const state = editor( original, { - type: 'INSERT_BLOCKS', - position: 1, - blocks: [ { - uid: 'persimmon', - name: 'core/freeform', - } ], - } ); - - expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 3 ); - expect( state.present.blockOrder ).toEqual( [ 'kumquat', 'persimmon', 'loquat' ] ); - } ); - - describe( 'edits()', () => { - it( 'should save newly edited properties', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: { - status: 'draft', - title: 'post title', - }, - } ); - - const state = editor( original, { - type: 'EDIT_POST', - edits: { - tags: [ 1 ], - }, - } ); - - expect( state.present.edits ).toEqual( { - status: 'draft', - title: 'post title', - tags: [ 1 ], - } ); - } ); - - it( 'should return same reference if no changed properties', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: { - status: 'draft', - title: 'post title', - }, - } ); - - const state = editor( original, { - type: 'EDIT_POST', - edits: { - status: 'draft', - }, - } ); - - expect( state.present.edits ).toBe( original.present.edits ); - } ); - - it( 'should save modified properties', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: { - status: 'draft', - title: 'post title', - tags: [ 1 ], - }, - } ); - - const state = editor( original, { - type: 'EDIT_POST', - edits: { - title: 'modified title', - tags: [ 2 ], - }, - } ); - - expect( state.present.edits ).toEqual( { - status: 'draft', - title: 'modified title', - tags: [ 2 ], - } ); - } ); - - it( 'should save initial post state', () => { - const state = editor( undefined, { - type: 'SETUP_NEW_POST', - edits: { - status: 'draft', - title: 'post title', - }, - } ); - - expect( state.present.edits ).toEqual( { - status: 'draft', - title: 'post title', - } ); - } ); - - it( 'should omit content when resetting', () => { - // Use case: When editing in Text mode, we defer to content on - // the property, but we reset blocks by parse when switching - // back to Visual mode. - const original = deepFreeze( editor( undefined, {} ) ); - let state = editor( original, { - type: 'EDIT_POST', - edits: { - content: 'bananas', - }, - } ); - - expect( state.present.edits ).toHaveProperty( 'content' ); - - state = editor( original, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'kumquat', - name: 'core/test-block', - attributes: {}, - }, { - uid: 'loquat', - name: 'core/test-block', - attributes: {}, - } ], - } ); - - expect( state.present.edits ).not.toHaveProperty( 'content' ); - } ); - } ); - - describe( 'blocksByUid', () => { - it( 'should return with attribute block updates', () => { - const original = deepFreeze( editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'kumquat', - attributes: {}, - } ], - } ) ); - const state = editor( original, { - type: 'UPDATE_BLOCK_ATTRIBUTES', - uid: 'kumquat', - attributes: { - updated: true, - }, - } ); - - expect( state.present.blocksByUid.kumquat.attributes.updated ).toBe( true ); - } ); - - it( 'should accumulate attribute block updates', () => { - const original = deepFreeze( editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'kumquat', - attributes: { - updated: true, - }, - } ], - } ) ); - const state = editor( original, { - type: 'UPDATE_BLOCK_ATTRIBUTES', - uid: 'kumquat', - attributes: { - moreUpdated: true, - }, - } ); - - expect( state.present.blocksByUid.kumquat.attributes ).toEqual( { - updated: true, - moreUpdated: true, - } ); - } ); - - it( 'should ignore updates to non-existant block', () => { - const original = deepFreeze( editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [], - } ) ); - const state = editor( original, { - type: 'UPDATE_BLOCK_ATTRIBUTES', - uid: 'kumquat', - attributes: { - updated: true, - }, - } ); - - expect( state.present.blocksByUid ).toBe( original.present.blocksByUid ); - } ); - - it( 'should return with same reference if no changes in updates', () => { - const original = deepFreeze( editor( undefined, { - type: 'RESET_BLOCKS', - blocks: [ { - uid: 'kumquat', - attributes: { - updated: true, - }, - } ], - } ) ); - const state = editor( original, { - type: 'UPDATE_BLOCK_ATTRIBUTES', - uid: 'kumquat', - attributes: { - updated: true, - }, - } ); - - expect( state.present.blocksByUid ).toBe( state.present.blocksByUid ); - } ); - } ); - } ); - - describe( 'currentPost()', () => { - it( 'should reset a post object', () => { - const original = deepFreeze( { title: 'unmodified' } ); - - const state = currentPost( original, { - type: 'RESET_POST', - post: { - title: 'new post', - }, - } ); - - expect( state ).toEqual( { - title: 'new post', - } ); - } ); - - it( 'should update the post object with UPDATE_POST', () => { - const original = deepFreeze( { title: 'unmodified', status: 'publish' } ); - - const state = currentPost( original, { - type: 'UPDATE_POST', - edits: { - title: 'updated post object from server', - }, - } ); - - expect( state ).toEqual( { - title: 'updated post object from server', - status: 'publish', - } ); - } ); - } ); - - describe( 'hoveredBlock()', () => { - it( 'should return with block uid as hovered', () => { - const state = hoveredBlock( null, { - type: 'TOGGLE_BLOCK_HOVERED', - uid: 'kumquat', - hovered: true, - } ); - - expect( state ).toBe( 'kumquat' ); - } ); - - it( 'should return null when a block is selected', () => { - const state = hoveredBlock( 'kumquat', { - type: 'SELECT_BLOCK', - uid: 'kumquat', - } ); - - expect( state ).toBeNull(); - } ); - - it( 'should replace the hovered block', () => { - const state = hoveredBlock( 'chicken', { - type: 'REPLACE_BLOCKS', - uids: [ 'chicken' ], - blocks: [ { - uid: 'wings', - name: 'core/freeform', - } ], - } ); - - expect( state ).toBe( 'wings' ); - } ); - - it( 'should keep the hovered block', () => { - const state = hoveredBlock( 'chicken', { - type: 'REPLACE_BLOCKS', - uids: [ 'ribs' ], - blocks: [ { - uid: 'wings', - name: 'core/freeform', - } ], - } ); - - expect( state ).toBe( 'chicken' ); - } ); - } ); - - describe( 'blockInsertionPoint', () => { - it( 'should default to an empty object', () => { - const state = blockInsertionPoint( undefined, {} ); - - expect( state ).toEqual( {} ); - } ); - - it( 'should set insertion point position', () => { - const state = blockInsertionPoint( undefined, { - type: 'SET_BLOCK_INSERTION_POINT', - position: 5, - } ); - - expect( state ).toEqual( { - position: 5, - } ); - } ); - - it( 'should clear insertion point position', () => { - const original = blockInsertionPoint( undefined, { - type: 'SET_BLOCK_INSERTION_POINT', - position: 5, - } ); - - const state = blockInsertionPoint( deepFreeze( original ), { - type: 'CLEAR_BLOCK_INSERTION_POINT', - } ); - - expect( state ).toEqual( { - position: null, - } ); - } ); - - it( 'should show the insertion point', () => { - const state = blockInsertionPoint( undefined, { - type: 'SHOW_INSERTION_POINT', - } ); - - expect( state ).toEqual( { visible: true } ); - } ); - - it( 'should clear the insertion point', () => { - const state = blockInsertionPoint( deepFreeze( {} ), { - type: 'HIDE_INSERTION_POINT', - } ); - - expect( state ).toEqual( { visible: false } ); - } ); - - it( 'should merge position and visible', () => { - const original = blockInsertionPoint( undefined, { - type: 'SHOW_INSERTION_POINT', - } ); - - const state = blockInsertionPoint( deepFreeze( original ), { - type: 'SET_BLOCK_INSERTION_POINT', - position: 5, - } ); - - expect( state ).toEqual( { - visible: true, - position: 5, - } ); - } ); - } ); - - describe( 'isTyping()', () => { - it( 'should set the typing flag to true', () => { - const state = isTyping( false, { - type: 'START_TYPING', - } ); - - expect( state ).toBe( true ); - } ); - - it( 'should set the typing flag to false', () => { - const state = isTyping( false, { - type: 'STOP_TYPING', - } ); - - expect( state ).toBe( false ); - } ); - } ); - - describe( 'blockSelection()', () => { - it( 'should return with block uid as selected', () => { - const state = blockSelection( undefined, { - type: 'SELECT_BLOCK', - uid: 'kumquat', - } ); - - expect( state ).toEqual( { start: 'kumquat', end: 'kumquat', focus: {}, isMultiSelecting: false } ); - } ); - - it( 'should set multi selection', () => { - const original = deepFreeze( { focus: { editable: 'citation' }, isMultiSelecting: false } ); - const state = blockSelection( original, { - type: 'MULTI_SELECT', - start: 'ribs', - end: 'chicken', - } ); - - expect( state ).toEqual( { start: 'ribs', end: 'chicken', focus: null, isMultiSelecting: false } ); - } ); - - it( 'should set continuous multi selection', () => { - const original = deepFreeze( { focus: { editable: 'citation' }, isMultiSelecting: true } ); - const state = blockSelection( original, { - type: 'MULTI_SELECT', - start: 'ribs', - end: 'chicken', - } ); - - expect( state ).toEqual( { start: 'ribs', end: 'chicken', focus: { editable: 'citation' }, isMultiSelecting: true } ); - } ); - - it( 'should start multi selection', () => { - const original = deepFreeze( { start: 'ribs', end: 'ribs', focus: { editable: 'citation' }, isMultiSelecting: false } ); - const state = blockSelection( original, { - type: 'START_MULTI_SELECT', - } ); - - expect( state ).toEqual( { start: 'ribs', end: 'ribs', focus: { editable: 'citation' }, isMultiSelecting: true } ); - } ); - - it( 'should end multi selection with selection', () => { - const original = deepFreeze( { start: 'ribs', end: 'chicken', focus: { editable: 'citation' }, isMultiSelecting: true } ); - const state = blockSelection( original, { - type: 'STOP_MULTI_SELECT', - } ); - - expect( state ).toEqual( { start: 'ribs', end: 'chicken', focus: null, isMultiSelecting: false } ); - } ); - - it( 'should end multi selection without selection', () => { - const original = deepFreeze( { start: 'ribs', end: 'ribs', focus: { editable: 'citation' }, isMultiSelecting: true } ); - const state = blockSelection( original, { - type: 'STOP_MULTI_SELECT', - } ); - - expect( state ).toEqual( { start: 'ribs', end: 'ribs', focus: { editable: 'citation' }, isMultiSelecting: false } ); - } ); - - it( 'should not update the state if the block is already selected', () => { - const original = deepFreeze( { start: 'ribs', end: 'ribs' } ); - - const state1 = blockSelection( original, { - type: 'SELECT_BLOCK', - uid: 'ribs', - } ); - - expect( state1 ).toBe( original ); - } ); - - it( 'should unset multi selection and select inserted block', () => { - const original = deepFreeze( { start: 'ribs', end: 'chicken' } ); - - const state1 = blockSelection( original, { - type: 'CLEAR_SELECTED_BLOCK', - } ); - - expect( state1 ).toEqual( { start: null, end: null, focus: null, isMultiSelecting: false } ); - - const state3 = blockSelection( original, { - type: 'INSERT_BLOCKS', - blocks: [ { - uid: 'ribs', - name: 'core/freeform', - } ], - } ); - - expect( state3 ).toEqual( { start: 'ribs', end: 'ribs', focus: {}, isMultiSelecting: false } ); - } ); - - it( 'should not update the state if the block moved is already selected', () => { - const original = deepFreeze( { start: 'ribs', end: 'ribs', focus: {} } ); - const state = blockSelection( original, { - type: 'MOVE_BLOCKS_UP', - uids: [ 'ribs' ], - } ); - - expect( state ).toBe( original ); - } ); - - it( 'should update the focus and selects the block', () => { - const state = blockSelection( undefined, { - type: 'UPDATE_FOCUS', - uid: 'chicken', - config: { editable: 'citation' }, - } ); - - expect( state ).toEqual( { start: 'chicken', end: 'chicken', focus: { editable: 'citation' }, isMultiSelecting: false } ); - } ); - - it( 'should update the focus and merge the existing state', () => { - const original = deepFreeze( { start: 'ribs', end: 'ribs', focus: {}, isMultiSelecting: true } ); - const state = blockSelection( original, { - type: 'UPDATE_FOCUS', - uid: 'ribs', - config: { editable: 'citation' }, - } ); - - expect( state ).toEqual( { start: 'ribs', end: 'ribs', focus: { editable: 'citation' }, isMultiSelecting: true } ); - } ); - - it( 'should replace the selected block', () => { - const original = deepFreeze( { start: 'chicken', end: 'chicken', focus: { editable: 'citation' } } ); - const state = blockSelection( original, { - type: 'REPLACE_BLOCKS', - uids: [ 'chicken' ], - blocks: [ { - uid: 'wings', - name: 'core/freeform', - } ], - } ); - - expect( state ).toEqual( { start: 'wings', end: 'wings', focus: {}, isMultiSelecting: false } ); - } ); - - it( 'should keep the selected block', () => { - const original = deepFreeze( { start: 'chicken', end: 'chicken', focus: { editable: 'citation' } } ); - const state = blockSelection( original, { - type: 'REPLACE_BLOCKS', - uids: [ 'ribs' ], - blocks: [ { - uid: 'wings', - name: 'core/freeform', - } ], - } ); - - expect( state ).toBe( original ); - } ); - } ); - - describe( 'preferences()', () => { - it( 'should apply all defaults', () => { - const state = preferences( undefined, {} ); - - expect( state ).toEqual( { - blockUsage: {}, - recentlyUsedBlocks: [], - mode: 'visual', - isSidebarOpened: true, - panels: { 'post-status': true }, - features: { fixedToolbar: true }, - } ); - } ); - - it( 'should toggle the sidebar open flag', () => { - const state = preferences( deepFreeze( { isSidebarOpened: false } ), { - type: 'TOGGLE_SIDEBAR', - } ); - - expect( state ).toEqual( { isSidebarOpened: true } ); - } ); - - it( 'should set the sidebar panel open flag to true if unset', () => { - const state = preferences( deepFreeze( { isSidebarOpened: false } ), { - type: 'TOGGLE_SIDEBAR_PANEL', - panel: 'post-taxonomies', - } ); - - expect( state ).toEqual( { isSidebarOpened: false, panels: { 'post-taxonomies': true } } ); - } ); - - it( 'should toggle the sidebar panel open flag', () => { - const state = preferences( deepFreeze( { isSidebarOpened: false, panels: { 'post-taxonomies': true } } ), { - type: 'TOGGLE_SIDEBAR_PANEL', - panel: 'post-taxonomies', - } ); - - expect( state ).toEqual( { isSidebarOpened: false, panels: { 'post-taxonomies': false } } ); - } ); - - it( 'should return switched mode', () => { - const state = preferences( deepFreeze( { isSidebarOpened: false } ), { - type: 'SWITCH_MODE', - mode: 'text', - } ); - - expect( state ).toEqual( { isSidebarOpened: false, mode: 'text' } ); - } ); - - it( 'should record recently used blocks', () => { - const state = preferences( deepFreeze( { recentlyUsedBlocks: [], blockUsage: {} } ), { - type: 'INSERT_BLOCKS', - blocks: [ { - uid: 'bacon', - name: 'core-embed/twitter', - } ], - } ); - - expect( state.recentlyUsedBlocks[ 0 ] ).toEqual( 'core-embed/twitter' ); - - const twoRecentBlocks = preferences( deepFreeze( { recentlyUsedBlocks: [], blockUsage: {} } ), { - type: 'INSERT_BLOCKS', - blocks: [ { - uid: 'eggs', - name: 'core-embed/twitter', - }, { - uid: 'bacon', - name: 'core-embed/youtube', - } ], - } ); - - expect( twoRecentBlocks.recentlyUsedBlocks[ 0 ] ).toEqual( 'core-embed/youtube' ); - expect( twoRecentBlocks.recentlyUsedBlocks[ 1 ] ).toEqual( 'core-embed/twitter' ); - } ); - - it( 'should record block usage', () => { - const state = preferences( deepFreeze( { recentlyUsedBlocks: [], blockUsage: {} } ), { - type: 'INSERT_BLOCKS', - blocks: [ { - uid: 'eggs', - name: 'core-embed/twitter', - }, { - uid: 'bacon', - name: 'core-embed/youtube', - }, { - uid: 'milk', - name: 'core-embed/youtube', - } ], - } ); - - expect( state.blockUsage ).toEqual( { 'core-embed/youtube': 2, 'core-embed/twitter': 1 } ); - } ); - - it( 'should populate recentlyUsedBlocks, filling up with common blocks, on editor setup', () => { - const state = preferences( deepFreeze( { recentlyUsedBlocks: [ 'core-embed/twitter', 'core-embed/youtube' ] } ), { - type: 'SETUP_EDITOR', - } ); - - expect( state.recentlyUsedBlocks[ 0 ] ).toEqual( 'core-embed/twitter' ); - expect( state.recentlyUsedBlocks[ 1 ] ).toEqual( 'core-embed/youtube' ); - - state.recentlyUsedBlocks.slice( 2 ).forEach( - block => expect( getBlockType( block ).category ).toEqual( 'common' ) - ); - expect( state.recentlyUsedBlocks ).toHaveLength( 8 ); - } ); - - it( 'should remove unregistered blocks from persisted recent usage', () => { - const state = preferences( deepFreeze( { recentlyUsedBlocks: [ 'core-embed/i-do-not-exist', 'core-embed/youtube' ] } ), { - type: 'SETUP_EDITOR', - } ); - expect( state.recentlyUsedBlocks[ 0 ] ).toEqual( 'core-embed/youtube' ); - } ); - - it( 'should remove unregistered blocks from persisted block usage stats', () => { - const state = preferences( deepFreeze( { recentlyUsedBlocks: [], blockUsage: { 'core/i-do-not-exist': 42, 'core-embed/youtube': 88 } } ), { - type: 'SETUP_EDITOR', - } ); - expect( state.blockUsage ).toEqual( { 'core-embed/youtube': 88 } ); - } ); - - it( 'should toggle a feature flag', () => { - const state = preferences( deepFreeze( { features: { chicken: true } } ), { - type: 'TOGGLE_FEATURE', - feature: 'chicken', - } ); - expect( state ).toEqual( { features: { chicken: false } } ); - } ); - } ); - - describe( 'saving()', () => { - it( 'should update when a request is started', () => { - const state = saving( null, { - type: 'REQUEST_POST_UPDATE', - } ); - expect( state ).toEqual( { - requesting: true, - successful: false, - error: null, - } ); - } ); - - it( 'should update when a request succeeds', () => { - const state = saving( null, { - type: 'REQUEST_POST_UPDATE_SUCCESS', - } ); - expect( state ).toEqual( { - requesting: false, - successful: true, - error: null, - } ); - } ); - - it( 'should update when a request fails', () => { - const state = saving( null, { - type: 'REQUEST_POST_UPDATE_FAILURE', - error: { - code: 'pretend_error', - message: 'update failed', - }, - } ); - expect( state ).toEqual( { - requesting: false, - successful: false, - error: { - code: 'pretend_error', - message: 'update failed', - }, - } ); - } ); - } ); - - describe( 'notices()', () => { - it( 'should create a notice', () => { - const originalState = [ - { - id: 'b', - content: 'Error saving', - status: 'error', - }, - ]; - const state = notices( deepFreeze( originalState ), { - type: 'CREATE_NOTICE', - notice: { - id: 'a', - content: 'Post saved', - status: 'success', - }, - } ); - expect( state ).toEqual( [ - originalState[ 0 ], - { - id: 'a', - content: 'Post saved', - status: 'success', - }, - ] ); - } ); - - it( 'should remove a notice', () => { - const originalState = [ - { - id: 'a', - content: 'Post saved', - status: 'success', - }, - { - id: 'b', - content: 'Error saving', - status: 'error', - }, - ]; - const state = notices( deepFreeze( originalState ), { - type: 'REMOVE_NOTICE', - noticeId: 'a', - } ); - expect( state ).toEqual( [ - originalState[ 1 ], - ] ); - } ); - } ); - - describe( 'blocksMode', () => { - it( 'should set mode to html if not set', () => { - const action = { - type: 'TOGGLE_BLOCK_MODE', - uid: 'chicken', - }; - const value = blocksMode( deepFreeze( {} ), action ); - - expect( value ).toEqual( { chicken: 'html' } ); - } ); - - it( 'should toggle mode to visual if set as html', () => { - const action = { - type: 'TOGGLE_BLOCK_MODE', - uid: 'chicken', - }; - const value = blocksMode( deepFreeze( { chicken: 'html' } ), action ); - - expect( value ).toEqual( { chicken: 'visual' } ); - } ); - } ); - - describe( 'metaBoxes()', () => { - it( 'should return default state', () => { - const actual = metaBoxes( undefined, {} ); - const expected = { - normal: { - isActive: false, - isDirty: false, - isUpdating: false, - }, - side: { - isActive: false, - isDirty: false, - isUpdating: false, - }, - }; - - expect( actual ).toEqual( expected ); - } ); - it( 'should set the sidebar to active', () => { - const theMetaBoxes = { - normal: false, - advanced: false, - side: true, - }; - - const action = { - type: 'INITIALIZE_META_BOX_STATE', - metaBoxes: theMetaBoxes, - }; - - const actual = metaBoxes( undefined, action ); - const expected = { - normal: { - isActive: false, - isDirty: false, - isUpdating: false, - isLoaded: false, - }, - side: { - isActive: true, - isDirty: false, - isUpdating: false, - isLoaded: false, - }, - }; - - expect( actual ).toEqual( expected ); - } ); - it( 'should switch updating to off', () => { - const action = { - type: 'HANDLE_META_BOX_RELOAD', - location: 'normal', - }; - - const theMetaBoxes = metaBoxes( { normal: { isUpdating: true, isActive: false, isDirty: true } }, action ); - const actual = theMetaBoxes.normal; - const expected = { - isActive: false, - isUpdating: false, - isDirty: false, - }; - - expect( actual ).toEqual( expected ); - } ); - it( 'should switch updating to on', () => { - const action = { - type: 'REQUEST_META_BOX_UPDATES', - locations: [ 'normal' ], - }; - - const theMetaBoxes = metaBoxes( undefined, action ); - const actual = theMetaBoxes.normal; - const expected = { - isActive: false, - isUpdating: true, - isDirty: false, - }; - - expect( actual ).toEqual( expected ); - } ); - it( 'should return with the isDirty flag as true', () => { - const action = { - type: 'META_BOX_STATE_CHANGED', - location: 'normal', - hasChanged: true, - }; - const theMetaBoxes = metaBoxes( undefined, action ); - const actual = theMetaBoxes.normal; - const expected = { - isActive: false, - isDirty: true, - isUpdating: false, - }; - - expect( actual ).toEqual( expected ); - } ); - } ); - - describe( 'reusableBlocks()', () => { - it( 'should start out empty', () => { - const state = reusableBlocks( undefined, {} ); - expect( state ).toEqual( { - data: {}, - isSaving: {}, - } ); - } ); - - it( 'should add fetched reusable blocks', () => { - const reusableBlock = { - id: '358b59ee-bab3-4d6f-8445-e8c6971a5605', - name: 'My cool block', - type: 'core/paragraph', - attributes: { - content: 'Hello!', - }, - }; - - const state = reusableBlocks( {}, { - type: 'FETCH_REUSABLE_BLOCKS_SUCCESS', - reusableBlocks: [ reusableBlock ], - } ); - - expect( state ).toEqual( { - data: { - [ reusableBlock.id ]: reusableBlock, - }, - isSaving: {}, - } ); - } ); - - it( 'should add a reusable block', () => { - const reusableBlock = { - id: '358b59ee-bab3-4d6f-8445-e8c6971a5605', - name: 'My cool block', - type: 'core/paragraph', - attributes: { - content: 'Hello!', - }, - }; - - const state = reusableBlocks( {}, { - type: 'UPDATE_REUSABLE_BLOCK', - id: reusableBlock.id, - reusableBlock, - } ); - - expect( state ).toEqual( { - data: { - [ reusableBlock.id ]: reusableBlock, - }, - isSaving: {}, - } ); - } ); - - it( 'should update a reusable block', () => { - const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - const initialState = { - data: { - [ id ]: { - id, - name: 'My cool block', - type: 'core/paragraph', - attributes: { - content: 'Hello!', - dropCap: true, - }, - }, - }, - isSaving: {}, - }; - - const state = reusableBlocks( initialState, { - type: 'UPDATE_REUSABLE_BLOCK', - id, - reusableBlock: { - name: 'My better block', - attributes: { - content: 'Yo!', - }, - }, - } ); - - expect( state ).toEqual( { - data: { - [ id ]: { - id, - name: 'My better block', - type: 'core/paragraph', - attributes: { - content: 'Yo!', - dropCap: true, - }, - }, - }, - isSaving: {}, - } ); - } ); - - it( 'should indicate that a reusable block is saving', () => { - const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - const initialState = { - data: {}, - isSaving: {}, - }; - - const state = reusableBlocks( initialState, { - type: 'SAVE_REUSABLE_BLOCK', - id, - } ); - - expect( state ).toEqual( { - data: {}, - isSaving: { - [ id ]: true, - }, - } ); - } ); - - it( 'should stop indicating that a reusable block is saving when the save succeeded', () => { - const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - const initialState = { - data: {}, - isSaving: { - [ id ]: true, - }, - }; - - const state = reusableBlocks( initialState, { - type: 'SAVE_REUSABLE_BLOCK_SUCCESS', - id, - } ); - - expect( state ).toEqual( { - data: {}, - isSaving: {}, - } ); - } ); - - it( 'should stop indicating that a reusable block is saving when there is an error', () => { - const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - const initialState = { - data: {}, - isSaving: { - [ id ]: true, - }, - }; - - const state = reusableBlocks( initialState, { - type: 'SAVE_REUSABLE_BLOCK_FAILURE', - id, - } ); - - expect( state ).toEqual( { - data: {}, - isSaving: {}, - } ); - } ); - } ); -} ); diff --git a/editor/state/test/reusable-blocks.js b/editor/state/test/reusable-blocks.js new file mode 100644 index 00000000000000..5b6dc9585027fb --- /dev/null +++ b/editor/state/test/reusable-blocks.js @@ -0,0 +1,344 @@ +/** + * Internal dependencies + */ +import reducer, { + fetchReusableBlocks, + updateReusableBlock, + saveReusableBlock, + convertBlockToStatic, + convertBlockToReusable, + getReusableBlock, + isSavingReusableBlock, + getReusableBlocks, +} from '../reusable-blocks'; + +describe( 'reusableBlocks', () => { + describe( 'reducer', () => { + it( 'should start out empty', () => { + const state = reducer( undefined, {} ); + expect( state ).toEqual( { + data: {}, + isSaving: {}, + } ); + } ); + + it( 'should add fetched reusable blocks', () => { + const reusableBlock = { + id: '358b59ee-bab3-4d6f-8445-e8c6971a5605', + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + }, + }; + + const state = reducer( {}, { + type: 'FETCH_REUSABLE_BLOCKS_SUCCESS', + reusableBlocks: [ reusableBlock ], + } ); + + expect( state ).toEqual( { + data: { + [ reusableBlock.id ]: reusableBlock, + }, + isSaving: {}, + } ); + } ); + + it( 'should add a reusable block', () => { + const reusableBlock = { + id: '358b59ee-bab3-4d6f-8445-e8c6971a5605', + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + }, + }; + + const state = reducer( {}, { + type: 'UPDATE_REUSABLE_BLOCK', + id: reusableBlock.id, + reusableBlock, + } ); + + expect( state ).toEqual( { + data: { + [ reusableBlock.id ]: reusableBlock, + }, + isSaving: {}, + } ); + } ); + + it( 'should update a reusable block', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const initialState = { + data: { + [ id ]: { + id, + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + dropCap: true, + }, + }, + }, + isSaving: {}, + }; + + const state = reducer( initialState, { + type: 'UPDATE_REUSABLE_BLOCK', + id, + reusableBlock: { + name: 'My better block', + attributes: { + content: 'Yo!', + }, + }, + } ); + + expect( state ).toEqual( { + data: { + [ id ]: { + id, + name: 'My better block', + type: 'core/paragraph', + attributes: { + content: 'Yo!', + dropCap: true, + }, + }, + }, + isSaving: {}, + } ); + } ); + + it( 'should indicate that a reusable block is saving', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const initialState = { + data: {}, + isSaving: {}, + }; + + const state = reducer( initialState, { + type: 'SAVE_REUSABLE_BLOCK', + id, + } ); + + expect( state ).toEqual( { + data: {}, + isSaving: { + [ id ]: true, + }, + } ); + } ); + + it( 'should stop indicating that a reusable block is saving when the save succeeded', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const initialState = { + data: {}, + isSaving: { + [ id ]: true, + }, + }; + + const state = reducer( initialState, { + type: 'SAVE_REUSABLE_BLOCK_SUCCESS', + id, + } ); + + expect( state ).toEqual( { + data: {}, + isSaving: {}, + } ); + } ); + + it( 'should stop indicating that a reusable block is saving when there is an error', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const initialState = { + data: {}, + isSaving: { + [ id ]: true, + }, + }; + + const state = reducer( initialState, { + type: 'SAVE_REUSABLE_BLOCK_FAILURE', + id, + } ); + + expect( state ).toEqual( { + data: {}, + isSaving: {}, + } ); + } ); + } ); + + describe( 'action creators', () => { + describe( 'fetchReusableBlocks', () => { + it( 'should return the FETCH_REUSABLE_BLOCKS action', () => { + expect( fetchReusableBlocks() ).toEqual( { + type: 'FETCH_REUSABLE_BLOCKS', + } ); + } ); + + it( 'should take an optional id argument', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + expect( fetchReusableBlocks( id ) ).toEqual( { + type: 'FETCH_REUSABLE_BLOCKS', + id, + } ); + } ); + } ); + + describe( 'updateReusableBlock', () => { + it( 'should return the UPDATE_REUSABLE_BLOCK action', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const reusableBlock = { + id, + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + }, + }; + expect( updateReusableBlock( id, reusableBlock ) ).toEqual( { + type: 'UPDATE_REUSABLE_BLOCK', + id, + reusableBlock, + } ); + } ); + } ); + + describe( 'saveReusableBlock', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + expect( saveReusableBlock( id ) ).toEqual( { + type: 'SAVE_REUSABLE_BLOCK', + id, + } ); + } ); + + describe( 'convertBlockToStatic', () => { + const uid = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + expect( convertBlockToStatic( uid ) ).toEqual( { + type: 'CONVERT_BLOCK_TO_STATIC', + uid, + } ); + } ); + + describe( 'convertBlockToReusable', () => { + const uid = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + expect( convertBlockToReusable( uid ) ).toEqual( { + type: 'CONVERT_BLOCK_TO_REUSABLE', + uid, + } ); + } ); + } ); + + describe( 'selectors', () => { + describe( 'getReusableBlock', () => { + it( 'should return a reusable block', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const expectedReusableBlock = { + id, + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + }, + }; + const state = { + reusableBlocks: { + data: { + [ id ]: expectedReusableBlock, + }, + }, + }; + + const actualReusableBlock = getReusableBlock( state, id ); + expect( actualReusableBlock ).toEqual( expectedReusableBlock ); + } ); + + it( 'should return null when no reusable block exists', () => { + const state = { + reusableBlocks: { + data: {}, + }, + }; + + const reusableBlock = getReusableBlock( state, '358b59ee-bab3-4d6f-8445-e8c6971a5605' ); + expect( reusableBlock ).toBeNull(); + } ); + } ); + + describe( 'isSavingReusableBlock', () => { + it( 'should return false when the block is not being saved', () => { + const state = { + reusableBlocks: { + isSaving: {}, + }, + }; + + const isSaving = isSavingReusableBlock( state, '358b59ee-bab3-4d6f-8445-e8c6971a5605' ); + expect( isSaving ).toBe( false ); + } ); + + it( 'should return true when the block is being saved', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const state = { + reusableBlocks: { + isSaving: { + [ id ]: true, + }, + }, + }; + + const isSaving = isSavingReusableBlock( state, id ); + expect( isSaving ).toBe( true ); + } ); + } ); + + describe( 'getReusableBlocks', () => { + it( 'should return an array of reusable blocks', () => { + const reusableBlock1 = { + id: '358b59ee-bab3-4d6f-8445-e8c6971a5605', + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + }, + }; + const reusableBlock2 = { + id: '687e1a87-cca1-41f2-a782-197ddaea9abf', + name: 'My neat block', + type: 'core/paragraph', + attributes: { + content: 'Goodbye!', + }, + }; + const state = { + reusableBlocks: { + data: { + [ reusableBlock1.id ]: reusableBlock1, + [ reusableBlock2.id ]: reusableBlock2, + }, + }, + }; + + const reusableBlocks = getReusableBlocks( state ); + expect( reusableBlocks ).toEqual( [ reusableBlock1, reusableBlock2 ] ); + } ); + + it( 'should return an empty array when no reusable blocks exist', () => { + const state = { + reusableBlocks: { + data: {}, + }, + }; + + const reusableBlocks = getReusableBlocks( state ); + expect( reusableBlocks ).toEqual( [] ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/saving.js b/editor/state/test/saving.js new file mode 100644 index 00000000000000..e3a64e5e6db8d6 --- /dev/null +++ b/editor/state/test/saving.js @@ -0,0 +1,120 @@ +/** + * Internal dependencies + */ +import reducer, { + isSavingPost, + didPostSaveRequestSucceed, + didPostSaveRequestFail, +} from '../saving'; + +describe( 'saving', () => { + describe( 'reducer', () => { + it( 'should update when a request is started', () => { + const state = reducer( null, { + type: 'REQUEST_POST_UPDATE', + } ); + expect( state ).toEqual( { + requesting: true, + successful: false, + error: null, + } ); + } ); + + it( 'should update when a request succeeds', () => { + const state = reducer( null, { + type: 'REQUEST_POST_UPDATE_SUCCESS', + } ); + expect( state ).toEqual( { + requesting: false, + successful: true, + error: null, + } ); + } ); + + it( 'should update when a request fails', () => { + const state = reducer( null, { + type: 'REQUEST_POST_UPDATE_FAILURE', + error: { + code: 'pretend_error', + message: 'update failed', + }, + } ); + expect( state ).toEqual( { + requesting: false, + successful: false, + error: { + code: 'pretend_error', + message: 'update failed', + }, + } ); + } ); + } ); + + describe( 'selectors', () => { + describe( 'isSavingPost', () => { + it( 'should return true if the post is currently being saved', () => { + const state = { + saving: { + requesting: true, + }, + }; + + expect( isSavingPost( state ) ).toBe( true ); + } ); + + it( 'should return false if the post is currently being saved', () => { + const state = { + saving: { + requesting: false, + }, + }; + + expect( isSavingPost( state ) ).toBe( false ); + } ); + } ); + + describe( 'didPostSaveRequestSucceed', () => { + it( 'should return true if the post save request is successful', () => { + const state = { + saving: { + successful: true, + }, + }; + + expect( didPostSaveRequestSucceed( state ) ).toBe( true ); + } ); + + it( 'should return true if the post save request has failed', () => { + const state = { + saving: { + successful: false, + }, + }; + + expect( didPostSaveRequestSucceed( state ) ).toBe( false ); + } ); + } ); + + describe( 'didPostSaveRequestFail', () => { + it( 'should return true if the post save request has failed', () => { + const state = { + saving: { + error: 'error', + }, + }; + + expect( didPostSaveRequestFail( state ) ).toBe( true ); + } ); + + it( 'should return true if the post save request is successful', () => { + const state = { + saving: { + error: false, + }, + }; + + expect( didPostSaveRequestFail( state ) ).toBe( false ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/selectors.js b/editor/state/test/selectors.js deleted file mode 100644 index 787a22cfb552e6..00000000000000 --- a/editor/state/test/selectors.js +++ /dev/null @@ -1,2190 +0,0 @@ -/** - * External dependencies - */ -import moment from 'moment'; - -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { - getEditorMode, - getPreference, - isEditorSidebarOpened, - isEditorSidebarPanelOpened, - hasEditorUndo, - hasEditorRedo, - isEditedPostNew, - isEditedPostDirty, - isCleanNewPost, - getCurrentPost, - getCurrentPostId, - getCurrentPostLastRevisionId, - getCurrentPostRevisionsCount, - getCurrentPostType, - getPostEdits, - getEditedPostTitle, - getDocumentTitle, - getEditedPostExcerpt, - getEditedPostVisibility, - isCurrentPostPublished, - isEditedPostPublishable, - isEditedPostSaveable, - isEditedPostBeingScheduled, - getEditedPostPreviewLink, - getBlock, - getBlocks, - getBlockCount, - getSelectedBlock, - getEditedPostContent, - getMultiSelectedBlockUids, - getMultiSelectedBlocks, - getMultiSelectedBlocksStartUid, - getMultiSelectedBlocksEndUid, - getBlockUids, - getBlockIndex, - isFirstBlock, - isLastBlock, - getPreviousBlock, - getNextBlock, - isBlockSelected, - isBlockWithinSelection, - isBlockMultiSelected, - isFirstMultiSelectedBlock, - isBlockHovered, - getBlockFocus, - getBlockMode, - isTyping, - getBlockInsertionPoint, - getBlockSiblingInserterPosition, - isBlockInsertionPointVisible, - isSavingPost, - didPostSaveRequestSucceed, - didPostSaveRequestFail, - getSuggestedPostFormat, - getNotices, - getMostFrequentlyUsedBlocks, - getRecentlyUsedBlocks, - getMetaBoxes, - getDirtyMetaBoxes, - getMetaBox, - isMetaBoxStateDirty, - getReusableBlock, - isSavingReusableBlock, - getReusableBlocks, -} from '../selectors'; - -describe( 'selectors', () => { - beforeAll( () => { - registerBlockType( 'core/test-block', { - save: ( props ) => props.attributes.text, - category: 'common', - title: 'test block', - } ); - } ); - - beforeEach( () => { - getDirtyMetaBoxes.clear(); - getBlock.clear(); - getBlocks.clear(); - getEditedPostContent.clear(); - getMultiSelectedBlockUids.clear(); - getMultiSelectedBlocks.clear(); - } ); - - afterAll( () => { - unregisterBlockType( 'core/test-block' ); - } ); - - describe( 'getEditorMode', () => { - it( 'should return the selected editor mode', () => { - const state = { - preferences: { mode: 'text' }, - }; - - expect( getEditorMode( state ) ).toEqual( 'text' ); - } ); - - it( 'should fallback to visual if not set', () => { - const state = { - preferences: {}, - }; - - expect( getEditorMode( state ) ).toEqual( 'visual' ); - } ); - } ); - - describe( 'getDirtyMetaBoxes', () => { - it( 'should return array of just the side location', () => { - const state = { - metaBoxes: { - normal: { - isActive: false, - isDirty: false, - isUpdating: false, - }, - side: { - isActive: true, - isDirty: true, - isUpdating: false, - }, - }, - }; - - expect( getDirtyMetaBoxes( state ) ).toEqual( [ 'side' ] ); - } ); - } ); - - describe( 'getMetaBoxes', () => { - it( 'should return the state of all meta boxes', () => { - const state = { - metaBoxes: { - normal: { - isDirty: false, - isUpdating: false, - }, - side: { - isDirty: false, - isUpdating: false, - }, - }, - }; - - expect( getMetaBoxes( state ) ).toEqual( { - normal: { - isDirty: false, - isUpdating: false, - }, - side: { - isDirty: false, - isUpdating: false, - }, - } ); - } ); - } ); - - describe( 'getMetaBox', () => { - it( 'should return the state of selected meta box', () => { - const state = { - metaBoxes: { - normal: { - isActive: false, - isDirty: false, - isUpdating: false, - }, - side: { - isActive: true, - isDirty: false, - isUpdating: false, - }, - }, - }; - - expect( getMetaBox( state, 'side' ) ).toEqual( { - isActive: true, - isDirty: false, - isUpdating: false, - } ); - } ); - } ); - - describe( 'isMetaBoxStateDirty', () => { - it( 'should return false', () => { - const state = { - metaBoxes: { - normal: { - isActive: false, - isDirty: false, - isUpdating: false, - }, - side: { - isActive: false, - isDirty: false, - isUpdating: false, - }, - }, - }; - - expect( isMetaBoxStateDirty( state ) ).toEqual( false ); - } ); - - it( 'should return false when a dirty meta box is not active.', () => { - const state = { - metaBoxes: { - normal: { - isActive: false, - isDirty: true, - isUpdating: false, - }, - side: { - isActive: false, - isDirty: false, - isUpdating: false, - }, - }, - }; - - expect( isMetaBoxStateDirty( state ) ).toEqual( false ); - } ); - - it( 'should return false when both meta boxes are dirty but inactive.', () => { - const state = { - metaBoxes: { - normal: { - isActive: false, - isDirty: true, - isUpdating: false, - }, - side: { - isActive: false, - isDirty: true, - isUpdating: false, - }, - }, - }; - - expect( isMetaBoxStateDirty( state ) ).toEqual( false ); - } ); - - it( 'should return false when a dirty meta box is active.', () => { - const state = { - metaBoxes: { - normal: { - isActive: true, - isDirty: true, - isUpdating: false, - }, - side: { - isActive: false, - isDirty: false, - isUpdating: false, - }, - }, - }; - - expect( isMetaBoxStateDirty( state ) ).toEqual( true ); - } ); - - it( 'should return false when both meta boxes are dirty and active.', () => { - const state = { - metaBoxes: { - normal: { - isActive: true, - isDirty: true, - isUpdating: false, - }, - side: { - isActive: true, - isDirty: true, - isUpdating: false, - }, - }, - }; - - expect( isMetaBoxStateDirty( state ) ).toEqual( true ); - } ); - } ); - - describe( 'getPreference', () => { - it( 'should return the preference value if set', () => { - const state = { - preferences: { chicken: true }, - }; - - expect( getPreference( state, 'chicken' ) ).toBe( true ); - } ); - - it( 'should return undefined if the preference is unset', () => { - const state = { - preferences: { chicken: true }, - }; - - expect( getPreference( state, 'ribs' ) ).toBeUndefined(); - } ); - - it( 'should return the default value if provided', () => { - const state = { - preferences: {}, - }; - - expect( getPreference( state, 'ribs', 'chicken' ) ).toEqual( 'chicken' ); - } ); - } ); - - describe( 'isEditorSidebarOpened', () => { - it( 'should return true when the sidebar is opened', () => { - const state = { - preferences: { isSidebarOpened: true }, - }; - - expect( isEditorSidebarOpened( state ) ).toBe( true ); - } ); - - it( 'should return false when the sidebar is opened', () => { - const state = { - preferences: { isSidebarOpened: false }, - }; - - expect( isEditorSidebarOpened( state ) ).toBe( false ); - } ); - } ); - - describe( 'isEditorSidebarPanelOpened', () => { - it( 'should return false if no panels preference', () => { - const state = { - preferences: { isSidebarOpened: true }, - }; - - expect( isEditorSidebarPanelOpened( state, 'post-taxonomies' ) ).toBe( false ); - } ); - - it( 'should return false if the panel value is not set', () => { - const state = { - preferences: { panels: {} }, - }; - - expect( isEditorSidebarPanelOpened( state, 'post-taxonomies' ) ).toBe( false ); - } ); - - it( 'should return the panel value', () => { - const state = { - preferences: { panels: { 'post-taxonomies': true } }, - }; - - expect( isEditorSidebarPanelOpened( state, 'post-taxonomies' ) ).toBe( true ); - } ); - } ); - - describe( 'hasEditorUndo', () => { - it( 'should return true when the past history is not empty', () => { - const state = { - editor: { - past: [ - {}, - ], - }, - }; - - expect( hasEditorUndo( state ) ).toBe( true ); - } ); - - it( 'should return false when the past history is empty', () => { - const state = { - editor: { - past: [], - }, - }; - - expect( hasEditorUndo( state ) ).toBe( false ); - } ); - } ); - - describe( 'hasEditorRedo', () => { - it( 'should return true when the future history is not empty', () => { - const state = { - editor: { - future: [ - {}, - ], - }, - }; - - expect( hasEditorRedo( state ) ).toBe( true ); - } ); - - it( 'should return false when the future history is empty', () => { - const state = { - editor: { - future: [], - }, - }; - - expect( hasEditorRedo( state ) ).toBe( false ); - } ); - } ); - - describe( 'isEditedPostNew', () => { - it( 'should return true when the post is new', () => { - const state = { - currentPost: { - status: 'auto-draft', - }, - editor: { - present: { - edits: {}, - }, - }, - }; - - expect( isEditedPostNew( state ) ).toBe( true ); - } ); - - it( 'should return false when the post is not new', () => { - const state = { - currentPost: { - status: 'draft', - }, - editor: { - present: { - edits: {}, - }, - }, - }; - - expect( isEditedPostNew( state ) ).toBe( false ); - } ); - } ); - - describe( 'isEditedPostDirty', () => { - const metaBoxes = { - normal: { - isActive: false, - isDirty: false, - isUpdating: false, - }, - side: { - isActive: false, - isDirty: false, - isUpdating: false, - }, - }; - // Those dirty dang meta boxes. - const dirtyMetaBoxes = { - normal: { - isActive: true, - isDirty: true, - isUpdating: false, - }, - side: { - isActive: false, - isDirty: false, - isUpdating: false, - }, - }; - - it( 'should return true when post saved state dirty', () => { - const state = { - editor: { - isDirty: true, - }, - metaBoxes, - }; - - expect( isEditedPostDirty( state ) ).toBe( true ); - } ); - - it( 'should return false when post saved state not dirty', () => { - const state = { - editor: { - isDirty: false, - }, - metaBoxes, - }; - - expect( isEditedPostDirty( state ) ).toBe( false ); - } ); - - it( 'should return true when post saved state not dirty, but meta box state has changed.', () => { - const state = { - editor: { - isDirty: false, - }, - metaBoxes: dirtyMetaBoxes, - }; - - expect( isEditedPostDirty( state ) ).toBe( true ); - } ); - } ); - - describe( 'isCleanNewPost', () => { - const metaBoxes = { isDirty: false, isUpdating: false }; - - it( 'should return true when the post is not dirty and has not been saved before', () => { - const state = { - editor: { - isDirty: false, - }, - currentPost: { - id: 1, - status: 'auto-draft', - }, - metaBoxes, - }; - - expect( isCleanNewPost( state ) ).toBe( true ); - } ); - - it( 'should return false when the post is not dirty but the post has been saved', () => { - const state = { - editor: { - isDirty: false, - }, - currentPost: { - id: 1, - status: 'draft', - }, - metaBoxes, - }; - - expect( isCleanNewPost( state ) ).toBe( false ); - } ); - - it( 'should return false when the post is dirty but the post has not been saved', () => { - const state = { - editor: { - isDirty: true, - }, - currentPost: { - id: 1, - status: 'auto-draft', - }, - metaBoxes, - }; - - expect( isCleanNewPost( state ) ).toBe( false ); - } ); - } ); - - describe( 'getCurrentPost', () => { - it( 'should return the current post', () => { - const state = { - currentPost: { id: 1 }, - }; - - expect( getCurrentPost( state ) ).toEqual( { id: 1 } ); - } ); - } ); - - describe( 'getCurrentPostId', () => { - it( 'should return null if the post has not yet been saved', () => { - const state = { - currentPost: {}, - }; - - expect( getCurrentPostId( state ) ).toBeNull(); - } ); - - it( 'should return the current post ID', () => { - const state = { - currentPost: { id: 1 }, - }; - - expect( getCurrentPostId( state ) ).toBe( 1 ); - } ); - } ); - - describe( 'getCurrentPostLastRevisionId', () => { - it( 'should return null if the post has not yet been saved', () => { - const state = { - currentPost: {}, - }; - - expect( getCurrentPostLastRevisionId( state ) ).toBeNull(); - } ); - - it( 'should return the last revision ID', () => { - const state = { - currentPost: { - revisions: { - last_id: 123, - }, - }, - }; - - expect( getCurrentPostLastRevisionId( state ) ).toBe( 123 ); - } ); - } ); - - describe( 'getCurrentPostRevisionsCount', () => { - it( 'should return 0 if the post has no revisions', () => { - const state = { - currentPost: {}, - }; - - expect( getCurrentPostRevisionsCount( state ) ).toBe( 0 ); - } ); - - it( 'should return the number of revisions', () => { - const state = { - currentPost: { - revisions: { - count: 5, - }, - }, - }; - - expect( getCurrentPostRevisionsCount( state ) ).toBe( 5 ); - } ); - } ); - - describe( 'getCurrentPostType', () => { - it( 'should return the post type', () => { - const state = { - currentPost: { - type: 'post', - }, - }; - - expect( getCurrentPostType( state ) ).toBe( 'post' ); - } ); - } ); - - describe( 'getPostEdits', () => { - it( 'should return the post edits', () => { - const state = { - editor: { - present: { - edits: { title: 'terga' }, - }, - }, - }; - - expect( getPostEdits( state ) ).toEqual( { title: 'terga' } ); - } ); - } ); - - describe( 'getEditedPostTitle', () => { - it( 'should return the post saved title if the title is not edited', () => { - const state = { - currentPost: { - title: 'sassel', - }, - editor: { - present: { - edits: { status: 'private' }, - }, - }, - }; - - expect( getEditedPostTitle( state ) ).toBe( 'sassel' ); - } ); - - it( 'should return the edited title', () => { - const state = { - currentPost: { - title: 'sassel', - }, - editor: { - present: { - edits: { title: 'youcha' }, - }, - }, - }; - - expect( getEditedPostTitle( state ) ).toBe( 'youcha' ); - } ); - } ); - - describe( 'getDocumentTitle', () => { - const metaBoxes = { isDirty: false, isUpdating: false }; - it( 'should return current title unedited existing post', () => { - const state = { - currentPost: { - id: 123, - title: 'The Title', - }, - editor: { - present: { - edits: {}, - blocksByUid: {}, - blockOrder: [], - }, - isDirty: false, - }, - metaBoxes, - }; - - expect( getDocumentTitle( state ) ).toBe( 'The Title' ); - } ); - - it( 'should return current title for edited existing post', () => { - const state = { - currentPost: { - id: 123, - title: 'The Title', - }, - editor: { - present: { - edits: { - title: 'Modified Title', - }, - }, - }, - metaBoxes, - }; - - expect( getDocumentTitle( state ) ).toBe( 'Modified Title' ); - } ); - - it( 'should return new post title when new post is clean', () => { - const state = { - currentPost: { - id: 1, - status: 'auto-draft', - title: '', - }, - editor: { - present: { - edits: {}, - blocksByUid: {}, - blockOrder: [], - }, - isDirty: false, - }, - metaBoxes, - }; - - expect( getDocumentTitle( state ) ).toBe( __( 'New post' ) ); - } ); - - it( 'should return untitled title', () => { - const state = { - currentPost: { - id: 123, - status: 'draft', - title: '', - }, - editor: { - present: { - edits: {}, - blocksByUid: {}, - blockOrder: [], - }, - isDirty: true, - }, - metaBoxes, - }; - - expect( getDocumentTitle( state ) ).toBe( __( '(Untitled)' ) ); - } ); - } ); - - describe( 'getEditedPostExcerpt', () => { - it( 'should return the post saved excerpt if the excerpt is not edited', () => { - const state = { - currentPost: { - excerpt: 'sassel', - }, - editor: { - present: { - edits: { status: 'private' }, - }, - }, - }; - - expect( getEditedPostExcerpt( state ) ).toBe( 'sassel' ); - } ); - - it( 'should return the edited excerpt', () => { - const state = { - currentPost: { - excerpt: 'sassel', - }, - editor: { - present: { - edits: { excerpt: 'youcha' }, - }, - }, - }; - - expect( getEditedPostExcerpt( state ) ).toBe( 'youcha' ); - } ); - } ); - - describe( 'getEditedPostVisibility', () => { - it( 'should return public by default', () => { - const state = { - currentPost: { - status: 'draft', - }, - editor: { - present: { - edits: {}, - }, - }, - }; - - expect( getEditedPostVisibility( state ) ).toBe( 'public' ); - } ); - - it( 'should return private for private posts', () => { - const state = { - currentPost: { - status: 'private', - }, - editor: { - present: { - edits: {}, - }, - }, - }; - - expect( getEditedPostVisibility( state ) ).toBe( 'private' ); - } ); - - it( 'should return private for password for password protected posts', () => { - const state = { - currentPost: { - status: 'draft', - password: 'chicken', - }, - editor: { - present: { - edits: {}, - }, - }, - }; - - expect( getEditedPostVisibility( state ) ).toBe( 'password' ); - } ); - - it( 'should use the edited status and password if edits present', () => { - const state = { - currentPost: { - status: 'draft', - password: 'chicken', - }, - editor: { - present: { - edits: { - status: 'private', - password: null, - }, - }, - }, - }; - - expect( getEditedPostVisibility( state ) ).toBe( 'private' ); - } ); - } ); - - describe( 'isCurrentPostPublished', () => { - it( 'should return true for public posts', () => { - const state = { - currentPost: { - status: 'publish', - }, - }; - - expect( isCurrentPostPublished( state ) ).toBe( true ); - } ); - - it( 'should return true for private posts', () => { - const state = { - currentPost: { - status: 'private', - }, - }; - - expect( isCurrentPostPublished( state ) ).toBe( true ); - } ); - - it( 'should return false for draft posts', () => { - const state = { - currentPost: { - status: 'draft', - }, - }; - - expect( isCurrentPostPublished( state ) ).toBe( false ); - } ); - - it( 'should return true for old scheduled posts', () => { - const state = { - currentPost: { - status: 'future', - date: '2016-05-30T17:21:39', - }, - }; - - expect( isCurrentPostPublished( state ) ).toBe( true ); - } ); - } ); - - describe( 'isEditedPostPublishable', () => { - const metaBoxes = { isDirty: false, isUpdating: false }; - - it( 'should return true for pending posts', () => { - const state = { - editor: { - isDirty: false, - }, - currentPost: { - status: 'pending', - }, - metaBoxes, - }; - - expect( isEditedPostPublishable( state ) ).toBe( true ); - } ); - - it( 'should return true for draft posts', () => { - const state = { - editor: { - isDirty: false, - }, - currentPost: { - status: 'draft', - }, - metaBoxes, - }; - - expect( isEditedPostPublishable( state ) ).toBe( true ); - } ); - - it( 'should return false for published posts', () => { - const state = { - editor: { - isDirty: false, - }, - currentPost: { - status: 'publish', - }, - metaBoxes, - }; - - expect( isEditedPostPublishable( state ) ).toBe( false ); - } ); - - it( 'should return true for published, dirty posts', () => { - const state = { - editor: { - isDirty: true, - }, - currentPost: { - status: 'publish', - }, - metaBoxes, - }; - - expect( isEditedPostPublishable( state ) ).toBe( true ); - } ); - - it( 'should return false for private posts', () => { - const state = { - editor: { - isDirty: false, - }, - currentPost: { - status: 'private', - }, - metaBoxes, - }; - - expect( isEditedPostPublishable( state ) ).toBe( false ); - } ); - - it( 'should return false for scheduled posts', () => { - const state = { - editor: { - isDirty: false, - }, - currentPost: { - status: 'future', - }, - metaBoxes, - }; - - expect( isEditedPostPublishable( state ) ).toBe( false ); - } ); - - it( 'should return true for dirty posts with usable title', () => { - const state = { - currentPost: { - status: 'private', - }, - editor: { - isDirty: true, - }, - metaBoxes, - }; - - expect( isEditedPostPublishable( state ) ).toBe( true ); - } ); - } ); - - describe( 'isEditedPostSaveable', () => { - it( 'should return false if the post has no title, excerpt, content', () => { - const state = { - editor: { - present: { - blocksByUid: {}, - blockOrder: [], - edits: {}, - }, - }, - currentPost: {}, - }; - - expect( isEditedPostSaveable( state ) ).toBe( false ); - } ); - - it( 'should return true if the post has a title', () => { - const state = { - editor: { - present: { - blocksByUid: {}, - blockOrder: [], - edits: {}, - }, - }, - currentPost: { - title: 'sassel', - }, - }; - - expect( isEditedPostSaveable( state ) ).toBe( true ); - } ); - - it( 'should return true if the post has an excerpt', () => { - const state = { - editor: { - present: { - blocksByUid: {}, - blockOrder: [], - edits: {}, - }, - }, - currentPost: { - excerpt: 'sassel', - }, - }; - - expect( isEditedPostSaveable( state ) ).toBe( true ); - } ); - - it( 'should return true if the post has content', () => { - const state = { - editor: { - present: { - blocksByUid: { - 123: { - uid: 123, - name: 'core/test-block', - attributes: { - text: '', - }, - }, - }, - blockOrder: [ 123 ], - edits: {}, - }, - }, - currentPost: {}, - }; - - expect( isEditedPostSaveable( state ) ).toBe( true ); - } ); - } ); - - describe( 'isEditedPostBeingScheduled', () => { - it( 'should return true for posts with a future date', () => { - const state = { - editor: { - present: { - edits: { date: moment().add( 7, 'days' ).format( '' ) }, - }, - }, - }; - - expect( isEditedPostBeingScheduled( state ) ).toBe( true ); - } ); - - it( 'should return false for posts with an old date', () => { - const state = { - editor: { - present: { - edits: { date: '2016-05-30T17:21:39' }, - }, - }, - }; - - expect( isEditedPostBeingScheduled( state ) ).toBe( false ); - } ); - } ); - - describe( 'getEditedPostPreviewLink', () => { - it( 'should return null if the post has not link yet', () => { - const state = { - currentPost: {}, - }; - - expect( getEditedPostPreviewLink( state ) ).toBeNull(); - } ); - - it( 'should return the correct url adding a preview parameter to the query string', () => { - const state = { - currentPost: { - link: 'https://andalouses.com/beach', - }, - }; - - expect( getEditedPostPreviewLink( state ) ).toBe( 'https://andalouses.com/beach?preview=true' ); - } ); - } ); - - describe( 'getBlock', () => { - it( 'should return the block', () => { - const state = { - currentPost: {}, - editor: { - present: { - blocksByUid: { - 123: { uid: 123, name: 'core/paragraph' }, - }, - edits: {}, - }, - }, - }; - - expect( getBlock( state, 123 ) ).toEqual( { uid: 123, name: 'core/paragraph' } ); - } ); - - it( 'should return null if the block is not present in state', () => { - const state = { - currentPost: {}, - editor: { - present: { - blocksByUid: {}, - edits: {}, - }, - }, - }; - - expect( getBlock( state, 123 ) ).toBe( null ); - } ); - - it( 'should merge meta attributes for the block', () => { - registerBlockType( 'core/meta-block', { - save: ( props ) => props.attributes.text, - category: 'common', - title: 'test block', - attributes: { - foo: { - type: 'string', - source: 'meta', - meta: 'foo', - }, - }, - } ); - - const state = { - currentPost: { - meta: { - foo: 'bar', - }, - }, - editor: { - present: { - blocksByUid: { - 123: { uid: 123, name: 'core/meta-block' }, - }, - edits: {}, - }, - }, - }; - - expect( getBlock( state, 123 ) ).toEqual( { - uid: 123, - name: 'core/meta-block', - attributes: { - foo: 'bar', - }, - } ); - } ); - } ); - - describe( 'getBlocks', () => { - it( 'should return the ordered blocks', () => { - const state = { - currentPost: {}, - editor: { - present: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, - }, - blockOrder: [ 123, 23 ], - edits: {}, - }, - }, - }; - - expect( getBlocks( state ) ).toEqual( [ - { uid: 123, name: 'core/paragraph' }, - { uid: 23, name: 'core/heading' }, - ] ); - } ); - } ); - - describe( 'getBlockCount', () => { - it( 'should return the number of blocks in the post', () => { - const state = { - editor: { - present: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, - }, - blockOrder: [ 123, 23 ], - }, - }, - }; - - expect( getBlockCount( state ) ).toBe( 2 ); - } ); - } ); - - describe( 'getSelectedBlock', () => { - it( 'should return null if no block is selected', () => { - const state = { - currentPost: {}, - editor: { - present: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, - }, - edits: {}, - }, - }, - blockSelection: { start: null, end: null }, - }; - - expect( getSelectedBlock( state ) ).toBe( null ); - } ); - - it( 'should return null if there is multi selection', () => { - const state = { - editor: { - present: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, - }, - }, - }, - blockSelection: { start: 23, end: 123 }, - }; - - expect( getSelectedBlock( state ) ).toBe( null ); - } ); - - it( 'should return the selected block', () => { - const state = { - editor: { - present: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, - }, - }, - }, - blockSelection: { start: 23, end: 23 }, - }; - - expect( getSelectedBlock( state ) ).toBe( state.editor.present.blocksByUid[ 23 ] ); - } ); - } ); - - describe( 'getMultiSelectedBlockUids', () => { - it( 'should return empty if there is no multi selection', () => { - const state = { - editor: { - present: { - blockOrder: [ 123, 23 ], - }, - }, - blockSelection: { start: null, end: null }, - }; - - expect( getMultiSelectedBlockUids( state ) ).toEqual( [] ); - } ); - - it( 'should return selected block uids if there is multi selection', () => { - const state = { - editor: { - present: { - blockOrder: [ 5, 4, 3, 2, 1 ], - }, - }, - blockSelection: { start: 2, end: 4 }, - }; - - expect( getMultiSelectedBlockUids( state ) ).toEqual( [ 4, 3, 2 ] ); - } ); - } ); - - describe( 'getMultiSelectedBlocksStartUid', () => { - it( 'returns null if there is no multi selection', () => { - const state = { - editor: { - present: { - blockOrder: [ 123, 23 ], - }, - }, - blockSelection: { start: null, end: null }, - }; - - expect( getMultiSelectedBlocksStartUid( state ) ).toBeNull(); - } ); - - it( 'returns multi selection start', () => { - const state = { - editor: { - present: { - blockOrder: [ 5, 4, 3, 2, 1 ], - }, - }, - blockSelection: { start: 2, end: 4 }, - }; - - expect( getMultiSelectedBlocksStartUid( state ) ).toBe( 2 ); - } ); - } ); - - describe( 'getMultiSelectedBlocksEndUid', () => { - it( 'returns null if there is no multi selection', () => { - const state = { - editor: { - present: { - blockOrder: [ 123, 23 ], - }, - }, - blockSelection: { start: null, end: null }, - }; - - expect( getMultiSelectedBlocksEndUid( state ) ).toBeNull(); - } ); - - it( 'returns multi selection end', () => { - const state = { - editor: { - present: { - blockOrder: [ 5, 4, 3, 2, 1 ], - }, - }, - blockSelection: { start: 2, end: 4 }, - }; - - expect( getMultiSelectedBlocksEndUid( state ) ).toBe( 4 ); - } ); - } ); - - describe( 'getBlockUids', () => { - it( 'should return the ordered block UIDs', () => { - const state = { - editor: { - present: { - blockOrder: [ 123, 23 ], - }, - }, - }; - - expect( getBlockUids( state ) ).toEqual( [ 123, 23 ] ); - } ); - } ); - - describe( 'getBlockIndex', () => { - it( 'should return the block order', () => { - const state = { - editor: { - present: { - blockOrder: [ 123, 23 ], - }, - }, - }; - - expect( getBlockIndex( state, 23 ) ).toBe( 1 ); - } ); - } ); - - describe( 'isFirstBlock', () => { - it( 'should return true when the block is first', () => { - const state = { - editor: { - present: { - blockOrder: [ 123, 23 ], - }, - }, - }; - - expect( isFirstBlock( state, 123 ) ).toBe( true ); - } ); - - it( 'should return false when the block is not first', () => { - const state = { - editor: { - present: { - blockOrder: [ 123, 23 ], - }, - }, - }; - - expect( isFirstBlock( state, 23 ) ).toBe( false ); - } ); - } ); - - describe( 'isLastBlock', () => { - it( 'should return true when the block is last', () => { - const state = { - editor: { - present: { - blockOrder: [ 123, 23 ], - }, - }, - }; - - expect( isLastBlock( state, 23 ) ).toBe( true ); - } ); - - it( 'should return false when the block is not last', () => { - const state = { - editor: { - present: { - blockOrder: [ 123, 23 ], - }, - }, - }; - - expect( isLastBlock( state, 123 ) ).toBe( false ); - } ); - } ); - - describe( 'getPreviousBlock', () => { - it( 'should return the previous block', () => { - const state = { - editor: { - present: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, - }, - blockOrder: [ 123, 23 ], - }, - }, - }; - - expect( getPreviousBlock( state, 23 ) ).toEqual( { uid: 123, name: 'core/paragraph' } ); - } ); - - it( 'should return null for the first block', () => { - const state = { - editor: { - present: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, - }, - blockOrder: [ 123, 23 ], - }, - }, - }; - - expect( getPreviousBlock( state, 123 ) ).toBeNull(); - } ); - } ); - - describe( 'getNextBlock', () => { - it( 'should return the following block', () => { - const state = { - editor: { - present: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, - }, - blockOrder: [ 123, 23 ], - }, - }, - }; - - expect( getNextBlock( state, 123 ) ).toEqual( { uid: 23, name: 'core/heading' } ); - } ); - - it( 'should return null for the last block', () => { - const state = { - editor: { - present: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, - }, - blockOrder: [ 123, 23 ], - }, - }, - }; - - expect( getNextBlock( state, 23 ) ).toBeNull(); - } ); - } ); - - describe( 'isBlockSelected', () => { - it( 'should return true if the block is selected', () => { - const state = { - blockSelection: { start: 123, end: 123 }, - }; - - expect( isBlockSelected( state, 123 ) ).toBe( true ); - } ); - - it( 'should return false if a multi-selection range exists', () => { - const state = { - blockSelection: { start: 123, end: 124 }, - }; - - expect( isBlockSelected( state, 123 ) ).toBe( false ); - } ); - - it( 'should return false if the block is not selected', () => { - const state = { - blockSelection: { start: null, end: null }, - }; - - expect( isBlockSelected( state, 23 ) ).toBe( false ); - } ); - } ); - - describe( 'isBlockWithinSelection', () => { - it( 'should return true if the block is selected but not the last', () => { - const state = { - blockSelection: { start: 5, end: 3 }, - editor: { - present: { - blockOrder: [ 5, 4, 3, 2, 1 ], - }, - }, - }; - - expect( isBlockWithinSelection( state, 4 ) ).toBe( true ); - } ); - - it( 'should return false if the block is the last selected', () => { - const state = { - blockSelection: { start: 5, end: 3 }, - editor: { - present: { - blockOrder: [ 5, 4, 3, 2, 1 ], - }, - }, - }; - - expect( isBlockWithinSelection( state, 3 ) ).toBe( false ); - } ); - - it( 'should return false if the block is not selected', () => { - const state = { - blockSelection: { start: 5, end: 3 }, - editor: { - present: { - blockOrder: [ 5, 4, 3, 2, 1 ], - }, - }, - }; - - expect( isBlockWithinSelection( state, 2 ) ).toBe( false ); - } ); - - it( 'should return false if there is no selection', () => { - const state = { - blockSelection: {}, - editor: { - present: { - blockOrder: [ 5, 4, 3, 2, 1 ], - }, - }, - }; - - expect( isBlockWithinSelection( state, 4 ) ).toBe( false ); - } ); - } ); - - describe( 'isBlockMultiSelected', () => { - const state = { - editor: { - present: { - blockOrder: [ 5, 4, 3, 2, 1 ], - }, - }, - blockSelection: { start: 2, end: 4 }, - }; - - it( 'should return true if the block is multi selected', () => { - expect( isBlockMultiSelected( state, 3 ) ).toBe( true ); - } ); - - it( 'should return false if the block is not multi selected', () => { - expect( isBlockMultiSelected( state, 5 ) ).toBe( false ); - } ); - } ); - - describe( 'isFirstMultiSelectedBlock', () => { - const state = { - editor: { - present: { - blockOrder: [ 5, 4, 3, 2, 1 ], - }, - }, - blockSelection: { start: 2, end: 4 }, - }; - - it( 'should return true if the block is first in multi selection', () => { - expect( isFirstMultiSelectedBlock( state, 4 ) ).toBe( true ); - } ); - - it( 'should return false if the block is not first in multi selection', () => { - expect( isFirstMultiSelectedBlock( state, 3 ) ).toBe( false ); - } ); - } ); - - describe( 'isBlockHovered', () => { - it( 'should return true if the block is hovered', () => { - const state = { - hoveredBlock: 123, - }; - - expect( isBlockHovered( state, 123 ) ).toBe( true ); - } ); - - it( 'should return false if the block is not hovered', () => { - const state = { - hoveredBlock: 123, - }; - - expect( isBlockHovered( state, 23 ) ).toBe( false ); - } ); - } ); - - describe( 'getBlockFocus', () => { - it( 'should return the block focus if the block is selected', () => { - const state = { - blockSelection: { - start: 123, - end: 123, - focus: { editable: 'cite' }, - }, - }; - - expect( getBlockFocus( state, 123 ) ).toEqual( { editable: 'cite' } ); - } ); - - it( 'should return the block focus for the start if the block is multi-selected', () => { - const state = { - blockSelection: { - start: 123, - end: 124, - focus: { editable: 'cite' }, - }, - }; - - expect( getBlockFocus( state, 123 ) ).toEqual( { editable: 'cite' } ); - } ); - - it( 'should return null for the end if the block is multi-selected', () => { - const state = { - blockSelection: { - start: 123, - end: 124, - focus: { editable: 'cite' }, - }, - }; - - expect( getBlockFocus( state, 124 ) ).toEqual( null ); - } ); - - it( 'should return null if the block is not selected', () => { - const state = { - blockSelection: { - start: 123, - end: 123, - focus: { editable: 'cite' }, - }, - }; - - expect( getBlockFocus( state, 23 ) ).toEqual( null ); - } ); - } ); - - describe( 'geteBlockMode', () => { - it( 'should return "visual" if unset', () => { - const state = { - blocksMode: {}, - }; - - expect( getBlockMode( state, 123 ) ).toEqual( 'visual' ); - } ); - - it( 'should return the block mode', () => { - const state = { - blocksMode: { - 123: 'html', - }, - }; - - expect( getBlockMode( state, 123 ) ).toEqual( 'html' ); - } ); - } ); - - describe( 'isTyping', () => { - it( 'should return the isTyping flag if the block is selected', () => { - const state = { - isTyping: true, - }; - - expect( isTyping( state ) ).toBe( true ); - } ); - - it( 'should return false if the block is not selected', () => { - const state = { - isTyping: false, - }; - - expect( isTyping( state ) ).toBe( false ); - } ); - } ); - - describe( 'getBlockInsertionPoint', () => { - it( 'should return the uid of the selected block', () => { - const state = { - currentPost: {}, - preferences: { mode: 'visual' }, - blockSelection: { - start: 2, - end: 2, - }, - editor: { - present: { - blocksByUid: { - 2: { uid: 2 }, - }, - blockOrder: [ 1, 2, 3 ], - edits: {}, - }, - }, - blockInsertionPoint: {}, - }; - - expect( getBlockInsertionPoint( state ) ).toBe( 2 ); - } ); - - it( 'should return the assigned insertion point', () => { - const state = { - preferences: { mode: 'visual' }, - blockSelection: {}, - editor: { - present: { - blockOrder: [ 1, 2, 3 ], - }, - }, - blockInsertionPoint: { - position: 2, - }, - }; - - expect( getBlockInsertionPoint( state ) ).toBe( 2 ); - } ); - - it( 'should return the last multi selected uid', () => { - const state = { - preferences: { mode: 'visual' }, - blockSelection: { - start: 1, - end: 2, - }, - editor: { - present: { - blockOrder: [ 1, 2, 3 ], - }, - }, - blockInsertionPoint: {}, - }; - - expect( getBlockInsertionPoint( state ) ).toBe( 2 ); - } ); - - it( 'should return the last block if no selection', () => { - const state = { - preferences: { mode: 'visual' }, - blockSelection: { start: null, end: null }, - editor: { - present: { - blockOrder: [ 1, 2, 3 ], - }, - }, - blockInsertionPoint: {}, - }; - - expect( getBlockInsertionPoint( state ) ).toBe( 3 ); - } ); - - it( 'should return the last block for the text mode', () => { - const state = { - preferences: { mode: 'text' }, - blockSelection: { start: 2, end: 2 }, - editor: { - present: { - blockOrder: [ 1, 2, 3 ], - }, - }, - blockInsertionPoint: {}, - }; - - expect( getBlockInsertionPoint( state ) ).toBe( 3 ); - } ); - } ); - - describe( 'getBlockSiblingInserterPosition', () => { - it( 'should return null if no sibling insertion point', () => { - const state = { - blockInsertionPoint: {}, - }; - - expect( getBlockSiblingInserterPosition( state ) ).toBe( null ); - } ); - - it( 'should return sibling insertion point', () => { - const state = { - blockInsertionPoint: { - position: 5, - }, - }; - - expect( getBlockSiblingInserterPosition( state ) ).toBe( 5 ); - } ); - } ); - - describe( 'isBlockInsertionPointVisible', () => { - it( 'should return the value in state', () => { - const state = { - blockInsertionPoint: { - visible: true, - }, - }; - - expect( isBlockInsertionPointVisible( state ) ).toBe( true ); - } ); - } ); - - describe( 'isSavingPost', () => { - it( 'should return true if the post is currently being saved', () => { - const state = { - saving: { - requesting: true, - }, - }; - - expect( isSavingPost( state ) ).toBe( true ); - } ); - - it( 'should return false if the post is currently being saved', () => { - const state = { - saving: { - requesting: false, - }, - }; - - expect( isSavingPost( state ) ).toBe( false ); - } ); - } ); - - describe( 'didPostSaveRequestSucceed', () => { - it( 'should return true if the post save request is successful', () => { - const state = { - saving: { - successful: true, - }, - }; - - expect( didPostSaveRequestSucceed( state ) ).toBe( true ); - } ); - - it( 'should return true if the post save request has failed', () => { - const state = { - saving: { - successful: false, - }, - }; - - expect( didPostSaveRequestSucceed( state ) ).toBe( false ); - } ); - } ); - - describe( 'didPostSaveRequestFail', () => { - it( 'should return true if the post save request has failed', () => { - const state = { - saving: { - error: 'error', - }, - }; - - expect( didPostSaveRequestFail( state ) ).toBe( true ); - } ); - - it( 'should return true if the post save request is successful', () => { - const state = { - saving: { - error: false, - }, - }; - - expect( didPostSaveRequestFail( state ) ).toBe( false ); - } ); - } ); - - describe( 'getSuggestedPostFormat', () => { - it( 'returns null if cannot be determined', () => { - const state = { - editor: { - present: { - blockOrder: [], - blocksByUid: {}, - }, - }, - }; - - expect( getSuggestedPostFormat( state ) ).toBeNull(); - } ); - - it( 'returns null if there is more than one block in the post', () => { - const state = { - editor: { - present: { - blockOrder: [ 123, 456 ], - blocksByUid: { - 123: { uid: 123, name: 'core/image' }, - 456: { uid: 456, name: 'core/quote' }, - }, - }, - }, - }; - - expect( getSuggestedPostFormat( state ) ).toBeNull(); - } ); - - it( 'returns Image if the first block is of type `core/image`', () => { - const state = { - editor: { - present: { - blockOrder: [ 123 ], - blocksByUid: { - 123: { uid: 123, name: 'core/image' }, - }, - }, - }, - }; - - expect( getSuggestedPostFormat( state ) ).toBe( 'image' ); - } ); - - it( 'returns Quote if the first block is of type `core/quote`', () => { - const state = { - editor: { - present: { - blockOrder: [ 456 ], - blocksByUid: { - 456: { uid: 456, name: 'core/quote' }, - }, - }, - }, - }; - - expect( getSuggestedPostFormat( state ) ).toBe( 'quote' ); - } ); - - it( 'returns Video if the first block is of type `core-embed/youtube`', () => { - const state = { - editor: { - present: { - blockOrder: [ 567 ], - blocksByUid: { - 567: { uid: 567, name: 'core-embed/youtube' }, - }, - }, - }, - }; - - expect( getSuggestedPostFormat( state ) ).toBe( 'video' ); - } ); - - it( 'returns Quote if the first block is of type `core/quote` and second is of type `core/paragraph`', () => { - const state = { - editor: { - present: { - blockOrder: [ 456, 789 ], - blocksByUid: { - 456: { uid: 456, name: 'core/quote' }, - 789: { uid: 789, name: 'core/paragraph' }, - }, - }, - }, - }; - - expect( getSuggestedPostFormat( state ) ).toBe( 'quote' ); - } ); - } ); - - describe( 'getNotices', () => { - it( 'should return the notices array', () => { - const state = { - notices: [ - { id: 'b', content: 'Post saved' }, - { id: 'a', content: 'Error saving' }, - ], - }; - - expect( getNotices( state ) ).toEqual( state.notices ); - } ); - } ); - - describe( 'getMostFrequentlyUsedBlocks', () => { - it( 'should have paragraph and image to bring frequently used blocks up to three blocks', () => { - const noUsage = { preferences: { blockUsage: {} } }; - const someUsage = { preferences: { blockUsage: { 'core/paragraph': 1 } } }; - - expect( getMostFrequentlyUsedBlocks( noUsage ).map( ( block ) => block.name ) ) - .toEqual( [ 'core/paragraph', 'core/image' ] ); - - expect( getMostFrequentlyUsedBlocks( someUsage ).map( ( block ) => block.name ) ) - .toEqual( [ 'core/paragraph', 'core/image' ] ); - } ); - it( 'should return the top 3 most recently used blocks', () => { - const state = { - preferences: { - blockUsage: { - 'core/deleted-block': 20, - 'core/paragraph': 4, - 'core/image': 11, - 'core/quote': 2, - 'core/gallery': 1, - }, - }, - }; - - expect( getMostFrequentlyUsedBlocks( state ).map( ( block ) => block.name ) ) - .toEqual( [ 'core/image', 'core/paragraph', 'core/quote' ] ); - } ); - } ); - - describe( 'getRecentlyUsedBlocks', () => { - it( 'should return the most recently used blocks', () => { - const state = { - preferences: { - recentlyUsedBlocks: [ 'core/deleted-block', 'core/paragraph', 'core/image' ], - }, - }; - - expect( getRecentlyUsedBlocks( state ).map( ( block ) => block.name ) ) - .toEqual( [ 'core/paragraph', 'core/image' ] ); - } ); - } ); - - describe( 'getReusableBlock', () => { - it( 'should return a reusable block', () => { - const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - const expectedReusableBlock = { - id, - name: 'My cool block', - type: 'core/paragraph', - attributes: { - content: 'Hello!', - }, - }; - const state = { - reusableBlocks: { - data: { - [ id ]: expectedReusableBlock, - }, - }, - }; - - const actualReusableBlock = getReusableBlock( state, id ); - expect( actualReusableBlock ).toEqual( expectedReusableBlock ); - } ); - - it( 'should return null when no reusable block exists', () => { - const state = { - reusableBlocks: { - data: {}, - }, - }; - - const reusableBlock = getReusableBlock( state, '358b59ee-bab3-4d6f-8445-e8c6971a5605' ); - expect( reusableBlock ).toBeNull(); - } ); - } ); - - describe( 'isSavingReusableBlock', () => { - it( 'should return false when the block is not being saved', () => { - const state = { - reusableBlocks: { - isSaving: {}, - }, - }; - - const isSaving = isSavingReusableBlock( state, '358b59ee-bab3-4d6f-8445-e8c6971a5605' ); - expect( isSaving ).toBe( false ); - } ); - - it( 'should return true when the block is being saved', () => { - const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - const state = { - reusableBlocks: { - isSaving: { - [ id ]: true, - }, - }, - }; - - const isSaving = isSavingReusableBlock( state, id ); - expect( isSaving ).toBe( true ); - } ); - } ); - - describe( 'getReusableBlocks', () => { - it( 'should return an array of reusable blocks', () => { - const reusableBlock1 = { - id: '358b59ee-bab3-4d6f-8445-e8c6971a5605', - name: 'My cool block', - type: 'core/paragraph', - attributes: { - content: 'Hello!', - }, - }; - const reusableBlock2 = { - id: '687e1a87-cca1-41f2-a782-197ddaea9abf', - name: 'My neat block', - type: 'core/paragraph', - attributes: { - content: 'Goodbye!', - }, - }; - const state = { - reusableBlocks: { - data: { - [ reusableBlock1.id ]: reusableBlock1, - [ reusableBlock2.id ]: reusableBlock2, - }, - }, - }; - - const reusableBlocks = getReusableBlocks( state ); - expect( reusableBlocks ).toEqual( [ reusableBlock1, reusableBlock2 ] ); - } ); - - it( 'should return an empty array when no reusable blocks exist', () => { - const state = { - reusableBlocks: { - data: {}, - }, - }; - - const reusableBlocks = getReusableBlocks( state ); - expect( reusableBlocks ).toEqual( [] ); - } ); - } ); -} ); diff --git a/editor/state/test/ui.js b/editor/state/test/ui.js new file mode 100644 index 00000000000000..2dedf004aad30b --- /dev/null +++ b/editor/state/test/ui.js @@ -0,0 +1,97 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { getDocumentTitle } from '../ui'; + +describe( 'ui', () => { + describe( 'selectors', () => { + describe( 'getDocumentTitle', () => { + const metaBoxes = { isDirty: false, isUpdating: false }; + it( 'should return current title unedited existing post', () => { + const state = { + currentPost: { + id: 123, + title: 'The Title', + }, + editor: { + present: { + edits: {}, + blocksByUid: {}, + blockOrder: [], + }, + isDirty: false, + }, + metaBoxes, + }; + + expect( getDocumentTitle( state ) ).toBe( 'The Title' ); + } ); + + it( 'should return current title for edited existing post', () => { + const state = { + currentPost: { + id: 123, + title: 'The Title', + }, + editor: { + present: { + edits: { + title: 'Modified Title', + }, + }, + }, + metaBoxes, + }; + + expect( getDocumentTitle( state ) ).toBe( 'Modified Title' ); + } ); + + it( 'should return new post title when new post is clean', () => { + const state = { + currentPost: { + id: 1, + status: 'auto-draft', + title: '', + }, + editor: { + present: { + edits: {}, + blocksByUid: {}, + blockOrder: [], + }, + isDirty: false, + }, + metaBoxes, + }; + + expect( getDocumentTitle( state ) ).toBe( __( 'New post' ) ); + } ); + + it( 'should return untitled title', () => { + const state = { + currentPost: { + id: 123, + status: 'draft', + title: '', + }, + editor: { + present: { + edits: {}, + blocksByUid: {}, + blockOrder: [], + }, + isDirty: true, + }, + metaBoxes, + }; + + expect( getDocumentTitle( state ) ).toBe( __( '(Untitled)' ) ); + } ); + } ); + } ); +} ); diff --git a/editor/state/test/utils.js b/editor/state/test/utils.js new file mode 100644 index 00000000000000..b24eac05a04540 --- /dev/null +++ b/editor/state/test/utils.js @@ -0,0 +1,20 @@ +/** + * Internal dependencies + */ +import { getPostRawValue } from '../utils'; + +describe( 'utils', () => { + describe( 'getPostRawValue', () => { + it( 'returns original value for non-rendered content', () => { + const value = getPostRawValue( '' ); + + expect( value ).toBe( '' ); + } ); + + it( 'returns raw value for rendered content', () => { + const value = getPostRawValue( { raw: '' } ); + + expect( value ).toBe( '' ); + } ); + } ); +} ); diff --git a/editor/state/ui.js b/editor/state/ui.js new file mode 100644 index 00000000000000..e8c9ab997337ed --- /dev/null +++ b/editor/state/ui.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { getEditedPostTitle, isCleanNewPost } from './editor'; + +/** + * Selectors + */ + +/** + * Gets the document title to be used. + * + * @param {Object} state Global application state + * @return {string} Document title + */ +export function getDocumentTitle( state ) { + let title = getEditedPostTitle( state ); + + if ( ! title.trim() ) { + title = isCleanNewPost( state ) ? __( 'New post' ) : __( '(Untitled)' ); + } + return title; +} diff --git a/editor/state/utils.js b/editor/state/utils.js new file mode 100644 index 00000000000000..d3c4853ca99254 --- /dev/null +++ b/editor/state/utils.js @@ -0,0 +1,14 @@ +/** + * Returns a post attribute value, flattening nested rendered content using its + * raw value in place of its original object form. + * + * @param {*} value Original value + * @return {*} Raw value + */ +export function getPostRawValue( value ) { + if ( value && 'object' === typeof value && 'raw' in value ) { + return value.raw; + } + + return value; +}