diff --git a/editor/reducer.js b/editor/reducer.js index 90111e7003c3c7..fa6e6c9d858ef0 100644 --- a/editor/reducer.js +++ b/editor/reducer.js @@ -4,6 +4,8 @@ import optimist from 'redux-optimist'; import { combineReducers } from 'redux'; import { + flow, + partialRight, difference, get, reduce, @@ -26,7 +28,8 @@ import { getBlockTypes, getBlockType } from '@wordpress/blocks'; /** * Internal dependencies */ -import { combineUndoableReducers } from './utils/undoable-reducer'; +import withHistory from './utils/with-history'; +import withChangeDetection from './utils/with-change-detection'; import { STORE_DEFAULTS } from './store-defaults'; /*** @@ -63,7 +66,16 @@ export function getPostRawValue( value ) { * @param {Object} action Dispatched action * @return {Object} Updated state */ -export const editor = combineUndoableReducers( { +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': @@ -261,7 +273,7 @@ export const editor = combineUndoableReducers( { return state; }, -}, { resetTypes: [ 'SETUP_EDITOR' ] } ); +} ); /** * Reducer returning the last-known state of the current post, in the format diff --git a/editor/selectors.js b/editor/selectors.js index f1cfcbe5a19540..02288e7e68dc29 100644 --- a/editor/selectors.js +++ b/editor/selectors.js @@ -6,10 +6,8 @@ import { first, get, has, - isEqual, last, reduce, - some, keys, without, compact, @@ -149,7 +147,7 @@ export function isEditorSidebarPanelOpened( state, panel ) { * @return {Boolean} Whether undo history exists */ export function hasEditorUndo( state ) { - return state.editor.history.past.length > 0; + return state.editor.past.length > 0; } /** @@ -160,7 +158,7 @@ export function hasEditorUndo( state ) { * @return {Boolean} Whether redo history exists */ export function hasEditorRedo( state ) { - return state.editor.history.future.length > 0; + return state.editor.future.length > 0; } /** @@ -181,49 +179,9 @@ export function isEditedPostNew( state ) { * @param {Object} state Global application state * @return {Boolean} Whether unsaved values exist */ -export const isEditedPostDirty = createSelector( - ( state ) => { - const edits = getPostEdits( state ); - const currentPost = getCurrentPost( state ); - const hasEditedAttributes = some( edits, ( value, key ) => { - return ! isEqual( value, currentPost[ key ] ); - } ); - - if ( hasEditedAttributes ) { - return true; - } - - if ( isMetaBoxStateDirty( state ) ) { - return true; - } - - // This is a cheaper operation that still must occur after checking - // attributes, because a post initialized with attributes different - // from its saved copy should be considered dirty. - if ( ! hasEditorUndo( state ) ) { - return false; - } - - // Check whether there are differences between editor from its original - // state (when history was last reset) and currently. Any difference in - // attributes, block type, order should consistute needing save. - const { history } = state.editor; - const originalEditor = history.past[ 0 ]; - const currentEditor = history.present; - return some( [ - 'blocksByUid', - 'blockOrder', - ], ( key ) => ! isEqual( - originalEditor[ key ], - currentEditor[ key ] - ) ); - }, - ( state ) => [ - state.editor, - state.currentPost, - state.metaBoxes, - ] -); +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 @@ -298,7 +256,7 @@ export function getCurrentPostLastRevisionId( state ) { * @return {Object} Object of key value pairs comprising unsaved edits */ export function getPostEdits( state ) { - return state.editor.edits; + return state.editor.present.edits; } /** @@ -311,9 +269,9 @@ export function getPostEdits( state ) { * @return {*} Post attribute value */ export function getEditedPostAttribute( state, attributeName ) { - return state.editor.edits[ attributeName ] === undefined ? + return state.editor.present.edits[ attributeName ] === undefined ? state.currentPost[ attributeName ] : - state.editor.edits[ attributeName ]; + state.editor.present.edits[ attributeName ]; } /** @@ -432,9 +390,9 @@ export function getDocumentTitle( state ) { * @return {String} Raw post excerpt */ export function getEditedPostExcerpt( state ) { - return state.editor.edits.excerpt === undefined ? + return state.editor.present.edits.excerpt === undefined ? state.currentPost.excerpt : - state.editor.edits.excerpt; + state.editor.present.edits.excerpt; } /** @@ -464,7 +422,7 @@ export function getEditedPostPreviewLink( state ) { */ export const getBlock = createSelector( ( state, uid ) => { - const block = state.editor.blocksByUid[ uid ]; + const block = state.editor.present.blocksByUid[ uid ]; if ( ! block ) { return null; } @@ -495,15 +453,15 @@ export const getBlock = createSelector( }; }, ( state, uid ) => [ - get( state, [ 'editor', 'blocksByUid', uid ] ), - get( state, 'editor.edits.meta' ), + 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', 'meta', key ] ) ? - get( state, [ 'editor', 'edits', 'meta', key ] ) : + return has( state, [ 'editor', 'edits', 'present', 'meta', key ] ) ? + get( state, [ 'editor', 'edits', 'present', 'meta', key ] ) : get( state, [ 'currentPost', 'meta', key ] ); } @@ -517,11 +475,11 @@ function getPostMeta( state, key ) { */ export const getBlocks = createSelector( ( state ) => { - return state.editor.blockOrder.map( ( uid ) => getBlock( state, uid ) ); + return state.editor.present.blockOrder.map( ( uid ) => getBlock( state, uid ) ); }, ( state ) => [ - state.editor.blockOrder, - state.editor.blocksByUid, + state.editor.present.blockOrder, + state.editor.present.blocksByUid, ] ); @@ -575,7 +533,7 @@ export function getSelectedBlock( state ) { */ export const getMultiSelectedBlockUids = createSelector( ( state ) => { - const { blockOrder } = state.editor; + const { blockOrder } = state.editor.present; const { start, end } = state.blockSelection; if ( start === end ) { return []; @@ -591,7 +549,7 @@ export const getMultiSelectedBlockUids = createSelector( return blockOrder.slice( startIndex, endIndex + 1 ); }, ( state ) => [ - state.editor.blockOrder, + state.editor.present.blockOrder, state.blockSelection.start, state.blockSelection.end, ], @@ -607,11 +565,11 @@ export const getMultiSelectedBlockUids = createSelector( export const getMultiSelectedBlocks = createSelector( ( state ) => getMultiSelectedBlockUids( state ).map( ( uid ) => getBlock( state, uid ) ), ( state ) => [ - state.editor.blockOrder, + state.editor.present.blockOrder, state.blockSelection.start, state.blockSelection.end, - state.editor.blocksByUid, - state.editor.edits.meta, + state.editor.present.blocksByUid, + state.editor.present.edits.meta, state.currentPost.meta, ] ); @@ -707,7 +665,7 @@ export function getMultiSelectedBlocksEndUid( state ) { * @return {Array} Ordered unique IDs of post blocks */ export function getBlockUids( state ) { - return state.editor.blockOrder; + return state.editor.present.blockOrder; } /** @@ -719,7 +677,7 @@ export function getBlockUids( state ) { * @return {Number} Index at which block exists in order */ export function getBlockIndex( state, uid ) { - return state.editor.blockOrder.indexOf( uid ); + return state.editor.present.blockOrder.indexOf( uid ); } /** @@ -731,7 +689,7 @@ export function getBlockIndex( state, uid ) { * @return {Boolean} Whether block is first in post */ export function isFirstBlock( state, uid ) { - return first( state.editor.blockOrder ) === uid; + return first( state.editor.present.blockOrder ) === uid; } /** @@ -743,7 +701,7 @@ export function isFirstBlock( state, uid ) { * @return {Boolean} Whether block is last in post */ export function isLastBlock( state, uid ) { - return last( state.editor.blockOrder ) === uid; + return last( state.editor.present.blockOrder ) === uid; } /** @@ -756,7 +714,7 @@ export function isLastBlock( state, uid ) { */ export function getPreviousBlock( state, uid ) { const order = getBlockIndex( state, uid ); - return state.editor.blocksByUid[ state.editor.blockOrder[ order - 1 ] ] || null; + return state.editor.present.blocksByUid[ state.editor.present.blockOrder[ order - 1 ] ] || null; } /** @@ -769,7 +727,7 @@ export function getPreviousBlock( state, uid ) { */ export function getNextBlock( state, uid ) { const order = getBlockIndex( state, uid ); - return state.editor.blocksByUid[ state.editor.blockOrder[ order + 1 ] ] || null; + return state.editor.present.blocksByUid[ state.editor.present.blockOrder[ order + 1 ] ] || null; } /** @@ -860,7 +818,7 @@ export function isTyping( state ) { */ export function getBlockInsertionPoint( state ) { if ( getEditorMode( state ) !== 'visual' ) { - return state.editor.blockOrder.length; + return state.editor.present.blockOrder.length; } const position = getBlockSiblingInserterPosition( state ); @@ -878,7 +836,7 @@ export function getBlockInsertionPoint( state ) { return getBlockIndex( state, selectedBlock.uid ) + 1; } - return state.editor.blockOrder.length; + return state.editor.present.blockOrder.length; } /** @@ -948,20 +906,20 @@ export function didPostSaveRequestFail( state ) { * @return {?String} Suggested post format */ export function getSuggestedPostFormat( state ) { - const blocks = state.editor.blockOrder; + 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.blocksByUid[ blocks[ 0 ] ].name; + 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.blocksByUid[ blocks[ 1 ] ].name === 'core/paragraph' ) { - name = state.editor.blocksByUid[ blocks[ 0 ] ].name; + if ( state.editor.present.blocksByUid[ blocks[ 1 ] ].name === 'core/paragraph' ) { + name = state.editor.present.blocksByUid[ blocks[ 0 ] ].name; } } @@ -1004,9 +962,9 @@ export const getEditedPostContent = createSelector( return serialize( getBlocks( state ) ); }, ( state ) => [ - state.editor.edits.content, - state.editor.blocksByUid, - state.editor.blockOrder, + state.editor.present.edits.content, + state.editor.present.blocksByUid, + state.editor.present.blockOrder, ], ); diff --git a/editor/test/reducer.js b/editor/test/reducer.js index be49a7badf7d9a..c82de4fe25ce28 100644 --- a/editor/test/reducer.js +++ b/editor/test/reducer.js @@ -56,12 +56,15 @@ describe( 'state', () => { unregisterBlockType( 'core/test-block' ); } ); - it( 'should return empty blocksByUid, blockOrder, history by default', () => { + it( 'should return history (empty edits, blocksByUid, blockOrder), dirty flag by default', () => { const state = editor( undefined, {} ); - expect( state.blocksByUid ).toEqual( {} ); - expect( state.blockOrder ).toEqual( [] ); - expect( state ).toHaveProperty( 'history' ); + 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', () => { @@ -71,9 +74,9 @@ describe( 'state', () => { blocks: [ { uid: 'bananas' } ], } ); - expect( Object.keys( state.blocksByUid ) ).toHaveLength( 1 ); - expect( values( state.blocksByUid )[ 0 ].uid ).toBe( 'bananas' ); - expect( state.blockOrder ).toEqual( [ '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', () => { @@ -93,9 +96,9 @@ describe( 'state', () => { } ], } ); - expect( Object.keys( state.blocksByUid ) ).toHaveLength( 2 ); - expect( values( state.blocksByUid )[ 1 ].uid ).toBe( 'ribs' ); - expect( state.blockOrder ).toEqual( [ 'chicken', 'ribs' ] ); + 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', () => { @@ -116,10 +119,10 @@ describe( 'state', () => { } ], } ); - expect( Object.keys( state.blocksByUid ) ).toHaveLength( 1 ); - expect( values( state.blocksByUid )[ 0 ].name ).toBe( 'core/freeform' ); - expect( values( state.blocksByUid )[ 0 ].uid ).toBe( 'wings' ); - expect( state.blockOrder ).toEqual( [ 'wings' ] ); + 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', () => { @@ -141,7 +144,7 @@ describe( 'state', () => { }, } ); - expect( state.blocksByUid.chicken ).toEqual( { + expect( state.present.blocksByUid.chicken ).toEqual( { uid: 'chicken', name: 'core/test-block', attributes: { content: 'ribs' }, @@ -167,7 +170,7 @@ describe( 'state', () => { uids: [ 'ribs' ], } ); - expect( state.blockOrder ).toEqual( [ 'ribs', 'chicken' ] ); + expect( state.present.blockOrder ).toEqual( [ 'ribs', 'chicken' ] ); } ); it( 'should move multiple blocks up', () => { @@ -192,7 +195,7 @@ describe( 'state', () => { uids: [ 'ribs', 'veggies' ], } ); - expect( state.blockOrder ).toEqual( [ 'ribs', 'veggies', 'chicken' ] ); + expect( state.present.blockOrder ).toEqual( [ 'ribs', 'veggies', 'chicken' ] ); } ); it( 'should not move the first block up', () => { @@ -213,7 +216,7 @@ describe( 'state', () => { uids: [ 'chicken' ], } ); - expect( state.blockOrder ).toBe( original.blockOrder ); + expect( state.present.blockOrder ).toBe( original.present.blockOrder ); } ); it( 'should move the block down', () => { @@ -234,7 +237,7 @@ describe( 'state', () => { uids: [ 'chicken' ], } ); - expect( state.blockOrder ).toEqual( [ 'ribs', 'chicken' ] ); + expect( state.present.blockOrder ).toEqual( [ 'ribs', 'chicken' ] ); } ); it( 'should move multiple blocks down', () => { @@ -259,7 +262,7 @@ describe( 'state', () => { uids: [ 'chicken', 'ribs' ], } ); - expect( state.blockOrder ).toEqual( [ 'veggies', 'chicken', 'ribs' ] ); + expect( state.present.blockOrder ).toEqual( [ 'veggies', 'chicken', 'ribs' ] ); } ); it( 'should not move the last block down', () => { @@ -280,7 +283,7 @@ describe( 'state', () => { uids: [ 'ribs' ], } ); - expect( state.blockOrder ).toBe( original.blockOrder ); + expect( state.present.blockOrder ).toBe( original.present.blockOrder ); } ); it( 'should remove the block', () => { @@ -301,8 +304,8 @@ describe( 'state', () => { uids: [ 'chicken' ], } ); - expect( state.blockOrder ).toEqual( [ 'ribs' ] ); - expect( state.blocksByUid ).toEqual( { + expect( state.present.blockOrder ).toEqual( [ 'ribs' ] ); + expect( state.present.blocksByUid ).toEqual( { ribs: { uid: 'ribs', name: 'core/test-block', @@ -333,8 +336,8 @@ describe( 'state', () => { uids: [ 'chicken', 'veggies' ], } ); - expect( state.blockOrder ).toEqual( [ 'ribs' ] ); - expect( state.blocksByUid ).toEqual( { + expect( state.present.blockOrder ).toEqual( [ 'ribs' ] ); + expect( state.present.blocksByUid ).toEqual( { ribs: { uid: 'ribs', name: 'core/test-block', @@ -366,8 +369,8 @@ describe( 'state', () => { } ], } ); - expect( Object.keys( state.blocksByUid ) ).toHaveLength( 3 ); - expect( state.blockOrder ).toEqual( [ 'kumquat', 'persimmon', 'loquat' ] ); + expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 3 ); + expect( state.present.blockOrder ).toEqual( [ 'kumquat', 'persimmon', 'loquat' ] ); } ); describe( 'edits()', () => { @@ -387,7 +390,7 @@ describe( 'state', () => { }, } ); - expect( state.edits ).toEqual( { + expect( state.present.edits ).toEqual( { status: 'draft', title: 'post title', tags: [ 1 ], @@ -410,7 +413,7 @@ describe( 'state', () => { }, } ); - expect( state.edits ).toBe( original.edits ); + expect( state.present.edits ).toBe( original.present.edits ); } ); it( 'should save modified properties', () => { @@ -431,7 +434,7 @@ describe( 'state', () => { }, } ); - expect( state.edits ).toEqual( { + expect( state.present.edits ).toEqual( { status: 'draft', title: 'modified title', tags: [ 2 ], @@ -447,7 +450,7 @@ describe( 'state', () => { }, } ); - expect( state.edits ).toEqual( { + expect( state.present.edits ).toEqual( { status: 'draft', title: 'post title', } ); @@ -465,7 +468,7 @@ describe( 'state', () => { }, } ); - expect( state.edits ).toHaveProperty( 'content' ); + expect( state.present.edits ).toHaveProperty( 'content' ); state = editor( original, { type: 'RESET_BLOCKS', @@ -480,7 +483,7 @@ describe( 'state', () => { } ], } ); - expect( state.edits ).not.toHaveProperty( 'content' ); + expect( state.present.edits ).not.toHaveProperty( 'content' ); } ); } ); @@ -501,7 +504,7 @@ describe( 'state', () => { }, } ); - expect( state.blocksByUid.kumquat.attributes.updated ).toBe( true ); + expect( state.present.blocksByUid.kumquat.attributes.updated ).toBe( true ); } ); it( 'should accumulate attribute block updates', () => { @@ -522,7 +525,7 @@ describe( 'state', () => { }, } ); - expect( state.blocksByUid.kumquat.attributes ).toEqual( { + expect( state.present.blocksByUid.kumquat.attributes ).toEqual( { updated: true, moreUpdated: true, } ); @@ -541,7 +544,7 @@ describe( 'state', () => { }, } ); - expect( state.blocksByUid ).toBe( original.blocksByUid ); + expect( state.present.blocksByUid ).toBe( original.present.blocksByUid ); } ); it( 'should return with same reference if no changes in updates', () => { @@ -562,7 +565,7 @@ describe( 'state', () => { }, } ); - expect( state.blocksByUid ).toBe( state.blocksByUid ); + expect( state.present.blocksByUid ).toBe( state.present.blocksByUid ); } ); } ); } ); diff --git a/editor/test/selectors.js b/editor/test/selectors.js index a1252667f4ee15..abd0839d0b706b 100644 --- a/editor/test/selectors.js +++ b/editor/test/selectors.js @@ -76,16 +76,6 @@ import { } from '../selectors'; describe( 'selectors', () => { - function getEditorState( states ) { - const past = [ ...states ]; - const present = past.pop(); - - return { - ...present, - history: { past, present }, - }; - } - beforeAll( () => { registerBlockType( 'core/test-block', { save: ( props ) => props.attributes.text, @@ -96,7 +86,6 @@ describe( 'selectors', () => { beforeEach( () => { getDirtyMetaBoxes.clear(); - isEditedPostDirty.clear(); getBlock.clear(); getBlocks.clear(); getEditedPostContent.clear(); @@ -371,11 +360,9 @@ describe( 'selectors', () => { it( 'should return true when the past history is not empty', () => { const state = { editor: { - history: { - past: [ - {}, - ], - }, + past: [ + {}, + ], }, }; @@ -385,9 +372,7 @@ describe( 'selectors', () => { it( 'should return false when the past history is empty', () => { const state = { editor: { - history: { - past: [], - }, + past: [], }, }; @@ -399,11 +384,9 @@ describe( 'selectors', () => { it( 'should return true when the future history is not empty', () => { const state = { editor: { - history: { - future: [ - {}, - ], - }, + future: [ + {}, + ], }, }; @@ -413,9 +396,7 @@ describe( 'selectors', () => { it( 'should return false when the future history is empty', () => { const state = { editor: { - history: { - future: [], - }, + future: [], }, }; @@ -430,7 +411,9 @@ describe( 'selectors', () => { status: 'auto-draft', }, editor: { - edits: {}, + present: { + edits: {}, + }, }, }; @@ -443,7 +426,9 @@ describe( 'selectors', () => { status: 'draft', }, editor: { - edits: {}, + present: { + edits: {}, + }, }, }; @@ -478,312 +463,33 @@ describe( 'selectors', () => { }, }; - it( 'should return true when the post has edited attributes', () => { - const state = { - currentPost: { - title: '', - }, - editor: getEditorState( [ - { - edits: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - blocksByUid: {}, - blockOrder: [], - }, - ] ), - metaBoxes, - }; - - expect( isEditedPostDirty( state ) ).toBe( true ); - } ); - - it( 'should return false when the post has no edited attributes and no past', () => { - const state = { - currentPost: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - editor: getEditorState( [ - { - edits: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - blocksByUid: {}, - blockOrder: [], - }, - ] ), - metaBoxes, - }; - - expect( isEditedPostDirty( state ) ).toBe( false ); - } ); - - it( 'should return false when the post has no edited attributes', () => { - const state = { - currentPost: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - editor: getEditorState( [ - { - edits: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - blocksByUid: {}, - blockOrder: [], - }, - { - edits: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - blocksByUid: { - 123: { - name: 'core/food', - attributes: { name: 'Chicken', delicious: false }, - }, - }, - blockOrder: [ - 123, - ], - }, - { - edits: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - blocksByUid: {}, - blockOrder: [], - }, - ] ), - metaBoxes, - }; - - expect( isEditedPostDirty( state ) ).toBe( false ); - } ); - - it( 'should return true when the post has edited block attributes', () => { + it( 'should return true when post saved state dirty', () => { const state = { - currentPost: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - editor: getEditorState( [ - { - edits: {}, - blocksByUid: { - 123: { - name: 'core/food', - attributes: { name: 'Chicken', delicious: false }, - }, - }, - blockOrder: [ - 123, - ], - }, - { - edits: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - blocksByUid: { - 123: { - name: 'core/food', - attributes: { name: 'Chicken', delicious: true }, - }, - }, - blockOrder: [ - 123, - ], - }, - ] ), - metaBoxes, - }; - - expect( isEditedPostDirty( state ) ).toBe( true ); - } ); - - it( 'should return true when the post has new blocks', () => { - const state = { - currentPost: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - editor: getEditorState( [ - { - edits: {}, - blocksByUid: { - 123: { - name: 'core/food', - attributes: { name: 'Chicken', delicious: true }, - }, - }, - blockOrder: [ - 123, - 456, - ], - }, - { - edits: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - blocksByUid: { - 123: { - name: 'core/food', - attributes: { name: 'Chicken', delicious: true }, - }, - 456: { - name: 'core/food', - attributes: { name: 'Ribs', delicious: true }, - }, - }, - blockOrder: [ - 123, - 456, - ], - }, - ] ), - metaBoxes, - }; - - expect( isEditedPostDirty( state ) ).toBe( true ); - } ); - - it( 'should return true when the post has changed blockĀ order', () => { - const state = { - currentPost: { - title: 'The Meat Eater\'s Guide to Delicious Meats', + editor: { + isDirty: true, }, - editor: getEditorState( [ - { - edits: {}, - blocksByUid: { - 123: { - name: 'core/food', - attributes: { name: 'Chicken', delicious: true }, - }, - 456: { - name: 'core/food', - attributes: { name: 'Ribs', delicious: true }, - }, - }, - blockOrder: [ - 123, - 456, - ], - }, - { - edits: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - blocksByUid: { - 123: { - name: 'core/food', - attributes: { name: 'Chicken', delicious: true }, - }, - 456: { - name: 'core/food', - attributes: { name: 'Ribs', delicious: true }, - }, - }, - blockOrder: [ - 456, - 123, - ], - }, - ] ), metaBoxes, }; expect( isEditedPostDirty( state ) ).toBe( true ); } ); - it( 'should return false when no edits, no changed block attributes, no changed order', () => { + it( 'should return false when post saved state not dirty', () => { const state = { - currentPost: { - title: 'The Meat Eater\'s Guide to Delicious Meats', + editor: { + isDirty: false, }, - editor: getEditorState( [ - { - edits: {}, - blocksByUid: { - 123: { - name: 'core/food', - attributes: { name: 'Chicken', delicious: true }, - }, - 456: { - name: 'core/food', - attributes: { name: 'Ribs', delicious: true }, - }, - }, - blockOrder: [ - 456, - 123, - ], - }, - { - edits: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - blocksByUid: { - 123: { - name: 'core/food', - attributes: { name: 'Chicken', delicious: true }, - }, - 456: { - name: 'core/food', - attributes: { name: 'Ribs', delicious: true }, - }, - }, - blockOrder: [ - 456, - 123, - ], - }, - ] ), metaBoxes, }; expect( isEditedPostDirty( state ) ).toBe( false ); } ); - it( 'should return true when no edits, no changed block attributes, no changed order, but meta box state has changed.', () => { + it( 'should return true when post saved state not dirty, but meta box state has changed.', () => { const state = { - currentPost: { - title: 'The Meat Eater\'s Guide to Delicious Meats', + editor: { + isDirty: false, }, - editor: getEditorState( [ - { - edits: {}, - blocksByUid: { - 123: { - name: 'core/food', - attributes: { name: 'Chicken', delicious: true }, - }, - 456: { - name: 'core/food', - attributes: { name: 'Ribs', delicious: true }, - }, - }, - blockOrder: [ - 456, - 123, - ], - }, - { - edits: { - title: 'The Meat Eater\'s Guide to Delicious Meats', - }, - blocksByUid: { - 123: { - name: 'core/food', - attributes: { name: 'Chicken', delicious: true }, - }, - 456: { - name: 'core/food', - attributes: { name: 'Ribs', delicious: true }, - }, - }, - blockOrder: [ - 456, - 123, - ], - }, - ] ), metaBoxes: dirtyMetaBoxes, }; @@ -796,11 +502,9 @@ describe( 'selectors', () => { it( 'should return true when the post is not dirty and has not been saved before', () => { const state = { - editor: getEditorState( [ - { - edits: {}, - }, - ] ), + editor: { + isDirty: false, + }, currentPost: { id: 1, status: 'auto-draft', @@ -813,11 +517,9 @@ describe( 'selectors', () => { it( 'should return false when the post is not dirty but the post has been saved', () => { const state = { - editor: getEditorState( [ - { - edits: {}, - }, - ] ), + editor: { + isDirty: false, + }, currentPost: { id: 1, status: 'draft', @@ -830,14 +532,9 @@ describe( 'selectors', () => { it( 'should return false when the post is dirty but the post has not been saved', () => { const state = { - editor: getEditorState( [ - { - edits: {}, - }, - { - edits: { title: 'Dirty' }, - }, - ] ), + editor: { + isDirty: true, + }, currentPost: { id: 1, status: 'auto-draft', @@ -937,7 +634,9 @@ describe( 'selectors', () => { it( 'should return the post edits', () => { const state = { editor: { - edits: { title: 'terga' }, + present: { + edits: { title: 'terga' }, + }, }, }; @@ -952,7 +651,9 @@ describe( 'selectors', () => { title: 'sassel', }, editor: { - edits: { status: 'private' }, + present: { + edits: { status: 'private' }, + }, }, }; @@ -965,7 +666,9 @@ describe( 'selectors', () => { title: 'sassel', }, editor: { - edits: { title: 'youcha' }, + present: { + edits: { title: 'youcha' }, + }, }, }; @@ -981,11 +684,14 @@ describe( 'selectors', () => { id: 123, title: 'The Title', }, - editor: getEditorState( [ - { + editor: { + present: { edits: {}, + blocksByUid: {}, + blockOrder: [], }, - ] ), + isDirty: false, + }, metaBoxes, }; @@ -998,14 +704,13 @@ describe( 'selectors', () => { id: 123, title: 'The Title', }, - editor: getEditorState( [ - { - edits: {}, - }, - { - edits: { title: 'Modified Title' }, + editor: { + present: { + edits: { + title: 'Modified Title', + }, }, - ] ), + }, metaBoxes, }; @@ -1019,11 +724,14 @@ describe( 'selectors', () => { status: 'auto-draft', title: '', }, - editor: getEditorState( [ - { + editor: { + present: { edits: {}, + blocksByUid: {}, + blockOrder: [], }, - ] ), + isDirty: false, + }, metaBoxes, }; @@ -1037,11 +745,14 @@ describe( 'selectors', () => { status: 'draft', title: '', }, - editor: getEditorState( [ - { + editor: { + present: { edits: {}, + blocksByUid: {}, + blockOrder: [], }, - ] ), + isDirty: true, + }, metaBoxes, }; @@ -1056,7 +767,9 @@ describe( 'selectors', () => { excerpt: 'sassel', }, editor: { - edits: { status: 'private' }, + present: { + edits: { status: 'private' }, + }, }, }; @@ -1069,7 +782,9 @@ describe( 'selectors', () => { excerpt: 'sassel', }, editor: { - edits: { excerpt: 'youcha' }, + present: { + edits: { excerpt: 'youcha' }, + }, }, }; @@ -1084,7 +799,9 @@ describe( 'selectors', () => { status: 'draft', }, editor: { - edits: {}, + present: { + edits: {}, + }, }, }; @@ -1097,7 +814,9 @@ describe( 'selectors', () => { status: 'private', }, editor: { - edits: {}, + present: { + edits: {}, + }, }, }; @@ -1111,7 +830,9 @@ describe( 'selectors', () => { password: 'chicken', }, editor: { - edits: {}, + present: { + edits: {}, + }, }, }; @@ -1125,9 +846,11 @@ describe( 'selectors', () => { password: 'chicken', }, editor: { - edits: { - status: 'private', - password: null, + present: { + edits: { + status: 'private', + password: null, + }, }, }, }; @@ -1184,14 +907,12 @@ describe( 'selectors', () => { it( 'should return true for pending posts', () => { const state = { + editor: { + isDirty: false, + }, currentPost: { status: 'pending', }, - editor: getEditorState( [ - { - edits: {}, - }, - ] ), metaBoxes, }; @@ -1200,14 +921,12 @@ describe( 'selectors', () => { it( 'should return true for draft posts', () => { const state = { + editor: { + isDirty: false, + }, currentPost: { status: 'draft', }, - editor: getEditorState( [ - { - edits: {}, - }, - ] ), metaBoxes, }; @@ -1216,30 +935,40 @@ describe( 'selectors', () => { it( 'should return false for published posts', () => { const state = { + editor: { + isDirty: false, + }, currentPost: { status: 'publish', }, - editor: getEditorState( [ - { - edits: {}, - }, - ] ), 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', }, - editor: getEditorState( [ - { - edits: {}, - }, - ] ), metaBoxes, }; @@ -1248,14 +977,12 @@ describe( 'selectors', () => { it( 'should return false for scheduled posts', () => { const state = { + editor: { + isDirty: false, + }, currentPost: { - status: 'private', + status: 'future', }, - editor: getEditorState( [ - { - edits: {}, - }, - ] ), metaBoxes, }; @@ -1267,14 +994,9 @@ describe( 'selectors', () => { currentPost: { status: 'private', }, - editor: getEditorState( [ - { - edits: {}, - }, - { - edits: { title: 'Dirty' }, - }, - ] ), + editor: { + isDirty: true, + }, metaBoxes, }; @@ -1286,9 +1008,11 @@ describe( 'selectors', () => { it( 'should return false if the post has no title, excerpt, content', () => { const state = { editor: { - blocksByUid: {}, - blockOrder: [], - edits: {}, + present: { + blocksByUid: {}, + blockOrder: [], + edits: {}, + }, }, currentPost: {}, }; @@ -1299,9 +1023,11 @@ describe( 'selectors', () => { it( 'should return true if the post has a title', () => { const state = { editor: { - blocksByUid: {}, - blockOrder: [], - edits: {}, + present: { + blocksByUid: {}, + blockOrder: [], + edits: {}, + }, }, currentPost: { title: 'sassel', @@ -1314,9 +1040,11 @@ describe( 'selectors', () => { it( 'should return true if the post has an excerpt', () => { const state = { editor: { - blocksByUid: {}, - blockOrder: [], - edits: {}, + present: { + blocksByUid: {}, + blockOrder: [], + edits: {}, + }, }, currentPost: { excerpt: 'sassel', @@ -1329,17 +1057,19 @@ describe( 'selectors', () => { it( 'should return true if the post has content', () => { const state = { editor: { - blocksByUid: { - 123: { - uid: 123, - name: 'core/test-block', - attributes: { - text: '', + present: { + blocksByUid: { + 123: { + uid: 123, + name: 'core/test-block', + attributes: { + text: '', + }, }, }, + blockOrder: [ 123 ], + edits: {}, }, - blockOrder: [ 123 ], - edits: {}, }, currentPost: {}, }; @@ -1352,7 +1082,9 @@ describe( 'selectors', () => { it( 'should return true for posts with a future date', () => { const state = { editor: { - edits: { date: moment().add( 7, 'days' ).format( '' ) }, + present: { + edits: { date: moment().add( 7, 'days' ).format( '' ) }, + }, }, }; @@ -1362,7 +1094,9 @@ describe( 'selectors', () => { it( 'should return false for posts with an old date', () => { const state = { editor: { - edits: { date: '2016-05-30T17:21:39' }, + present: { + edits: { date: '2016-05-30T17:21:39' }, + }, }, }; @@ -1395,10 +1129,12 @@ describe( 'selectors', () => { const state = { currentPost: {}, editor: { - blocksByUid: { - 123: { uid: 123, name: 'core/paragraph' }, + present: { + blocksByUid: { + 123: { uid: 123, name: 'core/paragraph' }, + }, + edits: {}, }, - edits: {}, }, }; @@ -1409,8 +1145,10 @@ describe( 'selectors', () => { const state = { currentPost: {}, editor: { - blocksByUid: {}, - edits: {}, + present: { + blocksByUid: {}, + edits: {}, + }, }, }; @@ -1437,10 +1175,12 @@ describe( 'selectors', () => { }, }, editor: { - blocksByUid: { - 123: { uid: 123, name: 'core/meta-block' }, + present: { + blocksByUid: { + 123: { uid: 123, name: 'core/meta-block' }, + }, + edits: {}, }, - edits: {}, }, }; @@ -1459,12 +1199,14 @@ describe( 'selectors', () => { const state = { currentPost: {}, editor: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], + edits: {}, }, - blockOrder: [ 123, 23 ], - edits: {}, }, }; @@ -1479,11 +1221,13 @@ describe( 'selectors', () => { it( 'should return the number of blocks in the post', () => { const state = { editor: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], }, - blockOrder: [ 123, 23 ], }, }; @@ -1496,11 +1240,13 @@ describe( 'selectors', () => { const state = { currentPost: {}, editor: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + edits: {}, }, - edits: {}, }, blockSelection: { start: null, end: null }, }; @@ -1511,9 +1257,11 @@ describe( 'selectors', () => { it( 'should return null if there is multi selection', () => { const state = { editor: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, }, }, blockSelection: { start: 23, end: 123 }, @@ -1525,15 +1273,17 @@ describe( 'selectors', () => { it( 'should return the selected block', () => { const state = { editor: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, + 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.blocksByUid[ 23 ] ); + expect( getSelectedBlock( state ) ).toBe( state.editor.present.blocksByUid[ 23 ] ); } ); } ); @@ -1541,7 +1291,9 @@ describe( 'selectors', () => { it( 'should return empty if there is no multi selection', () => { const state = { editor: { - blockOrder: [ 123, 23 ], + present: { + blockOrder: [ 123, 23 ], + }, }, blockSelection: { start: null, end: null }, }; @@ -1552,7 +1304,9 @@ describe( 'selectors', () => { it( 'should return selected block uids if there is multi selection', () => { const state = { editor: { - blockOrder: [ 5, 4, 3, 2, 1 ], + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, }, blockSelection: { start: 2, end: 4 }, }; @@ -1565,7 +1319,9 @@ describe( 'selectors', () => { it( 'returns null if there is no multi selection', () => { const state = { editor: { - blockOrder: [ 123, 23 ], + present: { + blockOrder: [ 123, 23 ], + }, }, blockSelection: { start: null, end: null }, }; @@ -1576,7 +1332,9 @@ describe( 'selectors', () => { it( 'returns multi selection start', () => { const state = { editor: { - blockOrder: [ 5, 4, 3, 2, 1 ], + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, }, blockSelection: { start: 2, end: 4 }, }; @@ -1589,7 +1347,9 @@ describe( 'selectors', () => { it( 'returns null if there is no multi selection', () => { const state = { editor: { - blockOrder: [ 123, 23 ], + present: { + blockOrder: [ 123, 23 ], + }, }, blockSelection: { start: null, end: null }, }; @@ -1600,7 +1360,9 @@ describe( 'selectors', () => { it( 'returns multi selection end', () => { const state = { editor: { - blockOrder: [ 5, 4, 3, 2, 1 ], + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, }, blockSelection: { start: 2, end: 4 }, }; @@ -1613,7 +1375,9 @@ describe( 'selectors', () => { it( 'should return the ordered block UIDs', () => { const state = { editor: { - blockOrder: [ 123, 23 ], + present: { + blockOrder: [ 123, 23 ], + }, }, }; @@ -1625,7 +1389,9 @@ describe( 'selectors', () => { it( 'should return the block order', () => { const state = { editor: { - blockOrder: [ 123, 23 ], + present: { + blockOrder: [ 123, 23 ], + }, }, }; @@ -1637,7 +1403,9 @@ describe( 'selectors', () => { it( 'should return true when the block is first', () => { const state = { editor: { - blockOrder: [ 123, 23 ], + present: { + blockOrder: [ 123, 23 ], + }, }, }; @@ -1647,7 +1415,9 @@ describe( 'selectors', () => { it( 'should return false when the block is not first', () => { const state = { editor: { - blockOrder: [ 123, 23 ], + present: { + blockOrder: [ 123, 23 ], + }, }, }; @@ -1659,7 +1429,9 @@ describe( 'selectors', () => { it( 'should return true when the block is last', () => { const state = { editor: { - blockOrder: [ 123, 23 ], + present: { + blockOrder: [ 123, 23 ], + }, }, }; @@ -1669,7 +1441,9 @@ describe( 'selectors', () => { it( 'should return false when the block is not last', () => { const state = { editor: { - blockOrder: [ 123, 23 ], + present: { + blockOrder: [ 123, 23 ], + }, }, }; @@ -1681,11 +1455,13 @@ describe( 'selectors', () => { it( 'should return the previous block', () => { const state = { editor: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], }, - blockOrder: [ 123, 23 ], }, }; @@ -1695,11 +1471,13 @@ describe( 'selectors', () => { it( 'should return null for the first block', () => { const state = { editor: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], }, - blockOrder: [ 123, 23 ], }, }; @@ -1711,11 +1489,13 @@ describe( 'selectors', () => { it( 'should return the following block', () => { const state = { editor: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], }, - blockOrder: [ 123, 23 ], }, }; @@ -1725,11 +1505,13 @@ describe( 'selectors', () => { it( 'should return null for the last block', () => { const state = { editor: { - blocksByUid: { - 23: { uid: 23, name: 'core/heading' }, - 123: { uid: 123, name: 'core/paragraph' }, + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: [ 123, 23 ], }, - blockOrder: [ 123, 23 ], }, }; @@ -1766,7 +1548,9 @@ describe( 'selectors', () => { describe( 'isBlockMultiSelected', () => { const state = { editor: { - blockOrder: [ 5, 4, 3, 2, 1 ], + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, }, blockSelection: { start: 2, end: 4 }, }; @@ -1783,7 +1567,9 @@ describe( 'selectors', () => { describe( 'isFirstMultiSelectedBlock', () => { const state = { editor: { - blockOrder: [ 5, 4, 3, 2, 1 ], + present: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, }, blockSelection: { start: 2, end: 4 }, }; @@ -1913,11 +1699,13 @@ describe( 'selectors', () => { end: 2, }, editor: { - blocksByUid: { - 2: { uid: 2 }, + present: { + blocksByUid: { + 2: { uid: 2 }, + }, + blockOrder: [ 1, 2, 3 ], + edits: {}, }, - blockOrder: [ 1, 2, 3 ], - edits: {}, }, blockInsertionPoint: {}, }; @@ -1930,7 +1718,9 @@ describe( 'selectors', () => { preferences: { mode: 'visual' }, blockSelection: {}, editor: { - blockOrder: [ 1, 2, 3 ], + present: { + blockOrder: [ 1, 2, 3 ], + }, }, blockInsertionPoint: { position: 2, @@ -1948,7 +1738,9 @@ describe( 'selectors', () => { end: 2, }, editor: { - blockOrder: [ 1, 2, 3 ], + present: { + blockOrder: [ 1, 2, 3 ], + }, }, blockInsertionPoint: {}, }; @@ -1961,7 +1753,9 @@ describe( 'selectors', () => { preferences: { mode: 'visual' }, blockSelection: { start: null, end: null }, editor: { - blockOrder: [ 1, 2, 3 ], + present: { + blockOrder: [ 1, 2, 3 ], + }, }, blockInsertionPoint: {}, }; @@ -1974,7 +1768,9 @@ describe( 'selectors', () => { preferences: { mode: 'text' }, blockSelection: { start: 2, end: 2 }, editor: { - blockOrder: [ 1, 2, 3 ], + present: { + blockOrder: [ 1, 2, 3 ], + }, }, blockInsertionPoint: {}, }; @@ -2085,8 +1881,10 @@ describe( 'selectors', () => { it( 'returns null if cannot be determined', () => { const state = { editor: { - blockOrder: [], - blocksByUid: {}, + present: { + blockOrder: [], + blocksByUid: {}, + }, }, }; @@ -2096,10 +1894,12 @@ describe( 'selectors', () => { it( 'returns null if there is more than one block in the post', () => { const state = { editor: { - blockOrder: [ 123, 456 ], - blocksByUid: { - 123: { uid: 123, name: 'core/image' }, - 456: { uid: 456, name: 'core/quote' }, + present: { + blockOrder: [ 123, 456 ], + blocksByUid: { + 123: { uid: 123, name: 'core/image' }, + 456: { uid: 456, name: 'core/quote' }, + }, }, }, }; @@ -2110,9 +1910,11 @@ describe( 'selectors', () => { it( 'returns Image if the first block is of type `core/image`', () => { const state = { editor: { - blockOrder: [ 123 ], - blocksByUid: { - 123: { uid: 123, name: 'core/image' }, + present: { + blockOrder: [ 123 ], + blocksByUid: { + 123: { uid: 123, name: 'core/image' }, + }, }, }, }; @@ -2123,9 +1925,11 @@ describe( 'selectors', () => { it( 'returns Quote if the first block is of type `core/quote`', () => { const state = { editor: { - blockOrder: [ 456 ], - blocksByUid: { - 456: { uid: 456, name: 'core/quote' }, + present: { + blockOrder: [ 456 ], + blocksByUid: { + 456: { uid: 456, name: 'core/quote' }, + }, }, }, }; @@ -2136,9 +1940,11 @@ describe( 'selectors', () => { it( 'returns Video if the first block is of type `core-embed/youtube`', () => { const state = { editor: { - blockOrder: [ 567 ], - blocksByUid: { - 567: { uid: 567, name: 'core-embed/youtube' }, + present: { + blockOrder: [ 567 ], + blocksByUid: { + 567: { uid: 567, name: 'core-embed/youtube' }, + }, }, }, }; @@ -2149,10 +1955,12 @@ describe( 'selectors', () => { it( 'returns Quote if the first block is of type `core/quote` and second is of type `core/paragraph`', () => { const state = { editor: { - blockOrder: [ 456, 789 ], - blocksByUid: { - 456: { uid: 456, name: 'core/quote' }, - 789: { uid: 789, name: 'core/paragraph' }, + present: { + blockOrder: [ 456, 789 ], + blocksByUid: { + 456: { uid: 456, name: 'core/quote' }, + 789: { uid: 789, name: 'core/paragraph' }, + }, }, }, }; diff --git a/editor/utils/test/undoable-reducer.js b/editor/utils/test/undoable-reducer.js deleted file mode 100644 index 9423cd8fe4f3ba..00000000000000 --- a/editor/utils/test/undoable-reducer.js +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Internal dependencies - */ -import { undoable, combineUndoableReducers } from '../undoable-reducer'; - -describe( 'undoableReducer', () => { - describe( 'undoable()', () => { - const counter = ( state = 0, { type } ) => ( - type === 'INCREMENT' ? state + 1 : state - ); - - it( 'should return a new reducer', () => { - const reducer = undoable( counter ); - - expect( typeof reducer ).toBe( 'function' ); - expect( reducer( undefined, {} ) ).toEqual( { - past: [], - present: 0, - future: [], - } ); - } ); - - it( 'should track history', () => { - const reducer = undoable( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 1, - future: [], - } ); - } ); - - it( 'should perform undo', () => { - const reducer = undoable( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'UNDO' } ); - - expect( state ).toEqual( { - past: [], - present: 0, - future: [ 1 ], - } ); - } ); - - it( 'should not perform undo on empty past', () => { - const reducer = undoable( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'UNDO' } ); - - expect( state ).toEqual( { - past: [], - present: 0, - future: [ 1 ], - } ); - } ); - - it( 'should perform redo', () => { - const reducer = undoable( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'UNDO' } ); - state = reducer( state, { type: 'UNDO' } ); - - expect( state ).toEqual( { - past: [], - present: 0, - future: [ 1 ], - } ); - } ); - - it( 'should not perform redo on empty future', () => { - const reducer = undoable( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'REDO' } ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 1, - future: [], - } ); - } ); - - it( 'should reset history by options.resetTypes', () => { - const reducer = undoable( counter, { resetTypes: [ 'RESET_HISTORY' ] } ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'RESET_HISTORY' } ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ 1, 2 ], - present: 3, - future: [], - } ); - } ); - } ); - - describe( 'combineUndoableReducers()', () => { - const reducer = combineUndoableReducers( { - count: ( state = 0 ) => state, - } ); - - it( 'should return a combined reducer with getters', () => { - const state = reducer( undefined, {} ); - - expect( typeof reducer ).toBe( 'function' ); - expect( state.count ).toBe( 0 ); - expect( state.history ).toEqual( { - past: [], - present: { - count: 0, - }, - future: [], - } ); - } ); - - it( 'should return same reference if state has not changed', () => { - const original = reducer( undefined, {} ); - const state = reducer( original, {} ); - - expect( state ).toBe( original ); - } ); - } ); -} ); diff --git a/editor/utils/with-change-detection/README.md b/editor/utils/with-change-detection/README.md new file mode 100644 index 00000000000000..75f6061483f832 --- /dev/null +++ b/editor/utils/with-change-detection/README.md @@ -0,0 +1,41 @@ +withChangeDetection +=================== + +`withChangeDetection` is a [Redux higher-order reducer](http://redux.js.org/docs/recipes/reducers/ReusingReducerLogic.html#customizing-behavior-with-higher-order-reducers) for tracking changes to reducer state over time. + +It does this based on the following assumptions: + +- The original reducer returns an object +- The original reducer returns a new reference only if a change has in-fact occurred + +Using these assumptions, the enhanced reducer returned from `withChangeDetection` will include a new property on the object `isDirty` corresponding to whether the original reference of the reducer has ever changed. + +Leveraging a `resetTypes` option, this can be used to mark intervals at which a state is considered to be clean (without changes) and dirty (with changes). + +## Example + +Considering a simple count reducer, we can enhance it with `withChangeDetection` to reflect whether changes have occurred: + +```js +function counter( state = { count: 0 }, action ) { + switch ( action.type ) { + case 'INCREMENT': + return { ...state, count: state.count + 1 }; + } + + return state; +} + +const enhancedCounter = withChangeDetection( counter, { resetTypes: [ 'RESET' ] } ); + +let state; + +state = enhancedCounter( undefined, {} ); +// { count: 0, isDirty: false } + +state = enhancedCounter( state, { type: 'INCREMENT' } ); +// { count: 1, isDirty: true } + +state = enhancedCounter( state, { type: 'RESET' } ); +// { count: 1, isDirty: false } +``` diff --git a/editor/utils/with-change-detection/index.js b/editor/utils/with-change-detection/index.js new file mode 100644 index 00000000000000..1a1fd842fe2f55 --- /dev/null +++ b/editor/utils/with-change-detection/index.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; + +/** + * Reducer enhancer for tracking changes to reducer state over time. The + * returned reducer will include a new `isDirty` property on the object + * reflecting whether the original reference of the reducer has changed. + * + * @param {Function} reducer Original reducer + * @param {?Object} options Optional options + * @param {?Array} options.resetTypes Action types upon which to reset dirty + * @return {Function} Enhanced reducer + */ +export default function withChangeDetection( reducer, options = {} ) { + let originalState; + + return ( state, action ) => { + let nextState = reducer( state, action ); + + // Reset at: + // - Initial state + // - Reset types + const isReset = ( + state === undefined || + includes( options.resetTypes, action.type ) + ); + + const nextIsDirty = ! isReset && originalState !== nextState; + + // Only revise state if changing. + if ( nextIsDirty !== nextState.isDirty ) { + // In case the original reducer returned the same reference and we + // intend to mutate, create a shallow clone. + if ( state === nextState ) { + nextState = { ...nextState }; + } + + nextState.isDirty = nextIsDirty; + } + + // Track original state against which dirty test compares reference + if ( isReset ) { + originalState = nextState; + } + + return nextState; + }; +} diff --git a/editor/utils/with-change-detection/test/index.js b/editor/utils/with-change-detection/test/index.js new file mode 100644 index 00000000000000..d2fede2528978a --- /dev/null +++ b/editor/utils/with-change-detection/test/index.js @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import withChangeDetection from '../'; + +describe( 'withChangeDetection()', () => { + const initialState = { count: 0 }; + + function originalReducer( state = initialState, action ) { + switch ( action.type ) { + case 'INCREMENT': + return { ...state, count: state.count + 1 }; + + case 'RESET_AND_CHANGE_REFERENCE': + return { ...state }; + + case 'SET_STATE': + return action.state; + } + + return state; + } + + it( 'should respect original reducer behavior', () => { + const reducer = withChangeDetection( originalReducer ); + + const state = reducer( undefined, {} ); + expect( state ).toEqual( { count: 0, isDirty: false } ); + + const nextState = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); + expect( nextState ).not.toBe( state ); + expect( nextState ).toEqual( { count: 1, isDirty: true } ); + } ); + + it( 'should allow reset types as option', () => { + const reducer = withChangeDetection( originalReducer, { resetTypes: [ 'RESET' ] } ); + + let state; + + state = reducer( undefined, {} ); + expect( state ).toEqual( { count: 0, isDirty: false } ); + + state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); + expect( state ).toEqual( { count: 1, isDirty: true } ); + + state = reducer( deepFreeze( state ), { type: 'RESET' } ); + expect( state ).toEqual( { count: 1, isDirty: false } ); + } ); + + it( 'should preserve isDirty into non-resetting non-reference-changing types', () => { + const reducer = withChangeDetection( originalReducer, { resetTypes: [ 'RESET' ] } ); + + let state; + + state = reducer( undefined, {} ); + expect( state ).toEqual( { count: 0, isDirty: false } ); + + state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); + expect( state ).toEqual( { count: 1, isDirty: true } ); + + state = reducer( deepFreeze( state ), {} ); + expect( state ).toEqual( { count: 1, isDirty: true } ); + } ); + + it( 'should reset if state reverts to its original reference', () => { + const reducer = withChangeDetection( originalReducer, { resetTypes: [ 'RESET' ] } ); + + let state; + + const originalState = state = reducer( undefined, {} ); + expect( state ).toEqual( { count: 0, isDirty: false } ); + + state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); + expect( state ).toEqual( { count: 1, isDirty: true } ); + + state = reducer( deepFreeze( state ), { type: 'SET_STATE', state: originalState } ); + expect( state ).toEqual( { count: 0, isDirty: false } ); + } ); + + it( 'should flag as not dirty even if reset type causes reference change', () => { + const reducer = withChangeDetection( originalReducer, { resetTypes: [ 'RESET_AND_CHANGE_REFERENCE' ] } ); + + let state; + + state = reducer( undefined, {} ); + expect( state ).toEqual( { count: 0, isDirty: false } ); + + state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); + expect( state ).toEqual( { count: 1, isDirty: true } ); + + state = reducer( deepFreeze( state ), { type: 'RESET_AND_CHANGE_REFERENCE' } ); + expect( state ).toEqual( { count: 1, isDirty: false } ); + } ); +} ); diff --git a/editor/utils/with-history/README.md b/editor/utils/with-history/README.md new file mode 100644 index 00000000000000..52b515d1159cdf --- /dev/null +++ b/editor/utils/with-history/README.md @@ -0,0 +1,42 @@ +withHistory +=========== + +`withHistory` is a [Redux higher-order reducer](http://redux.js.org/docs/recipes/reducers/ReusingReducerLogic.html#customizing-behavior-with-higher-order-reducers) for tracking the history of a reducer state over time. The enhanced reducer returned from `withHistory` will return an object shape with properties `past`, `present`, and `future`. The `present` value maintains the current value of state returned from the original reducer. Past and future are respectively maintained as arrays of state values occuring previously and future (if history undone). + +Leveraging a `resetTypes` option, this can be used to mark intervals at which a state history should be reset, emptying the values of the `past` and `future` arrays. + +History can be adjusted by dispatching actions with type `UNDO` (reset to the previous state) and `REDO` (reset to the next state). + +## Example + +Considering a simple count reducer, we can enhance it with `withHistory` to track value over time: + +```js +function counter( state = { count: 0 }, action ) { + switch ( action.type ) { + case 'INCREMENT': + return { ...state, count: state.count + 1 }; + } + + return state; +} + +const enhancedCounter = withHistory( counter, { resetTypes: [ 'RESET' ] } ); + +let state; + +state = enhancedCounter( undefined, {} ); +// { past: [], present: 0, future: [] } + +state = enhancedCounter( state, { type: 'INCREMENT' } ); +// { past: [ 0 ], present: 1, future: [] } + +state = enhancedCounter( state, { type: 'UNDO' } ); +// { past: [], present: 0, future: [ 1 ] } + +state = enhancedCounter( state, { type: 'REDO' } ); +// { past: [ 0 ], present: 1, future: [] } + +state = enhancedCounter( state, { type: 'RESET' } ); +// { past: [], present: 1, future: [] } +``` diff --git a/editor/utils/undoable-reducer.js b/editor/utils/with-history/index.js similarity index 53% rename from editor/utils/undoable-reducer.js rename to editor/utils/with-history/index.js index a7d2e97681df9b..7fa250d1319e79 100644 --- a/editor/utils/undoable-reducer.js +++ b/editor/utils/with-history/index.js @@ -1,7 +1,6 @@ /** * External dependencies */ -import { combineReducers } from 'redux'; import { includes } from 'lodash'; /** @@ -13,7 +12,7 @@ import { includes } from 'lodash'; * @param {?Array} options.resetTypes Action types upon which to clear past * @return {Function} Enhanced reducer */ -export function undoable( reducer, options = {} ) { +export default function withHistory( reducer, options = {} ) { const initialState = { past: [], present: reducer( undefined, {} ), @@ -70,46 +69,3 @@ export function undoable( reducer, options = {} ) { }; }; } - -/** - * A wrapper for combineReducers which applies an undo history to the combined - * reducer. As a convenience, properties of the reducers object are accessible - * via object getters, with history assigned to a nested history property. - * - * @see undoable - * - * @param {Object} reducers Object of reducers - * @param {?Object} options Optional options - * @return {Function} Combined reducer - */ -export function combineUndoableReducers( reducers, options ) { - const reducer = undoable( combineReducers( reducers ), options ); - - function withGetters( history ) { - const state = { history }; - const keys = Object.getOwnPropertyNames( history.present ); - const getters = keys.reduce( ( memo, key ) => { - memo[ key ] = { - get: function() { - return this.history.present[ key ]; - }, - }; - - return memo; - }, {} ); - Object.defineProperties( state, getters ); - - return state; - } - - const initialState = withGetters( reducer( undefined, {} ) ); - - return ( state = initialState, action ) => { - const nextState = reducer( state.history, action ); - if ( nextState === state.history ) { - return state; - } - - return withGetters( nextState ); - }; -} diff --git a/editor/utils/with-history/test/index.js b/editor/utils/with-history/test/index.js new file mode 100644 index 00000000000000..d59dcfc39847f2 --- /dev/null +++ b/editor/utils/with-history/test/index.js @@ -0,0 +1,121 @@ +/** + * Internal dependencies + */ +import withHistory from '../'; + +describe( 'withHistory', () => { + const counter = ( state = 0, { type } ) => ( + type === 'INCREMENT' ? state + 1 : state + ); + + it( 'should return a new reducer', () => { + const reducer = withHistory( counter ); + + expect( typeof reducer ).toBe( 'function' ); + expect( reducer( undefined, {} ) ).toEqual( { + past: [], + present: 0, + future: [], + } ); + } ); + + it( 'should track history', () => { + const reducer = withHistory( counter ); + + let state; + state = reducer( undefined, {} ); + state = reducer( state, { type: 'INCREMENT' } ); + + expect( state ).toEqual( { + past: [ 0 ], + present: 1, + future: [], + } ); + } ); + + it( 'should perform undo', () => { + const reducer = withHistory( counter ); + + let state; + state = reducer( undefined, {} ); + state = reducer( state, { type: 'INCREMENT' } ); + state = reducer( state, { type: 'UNDO' } ); + + expect( state ).toEqual( { + past: [], + present: 0, + future: [ 1 ], + } ); + } ); + + it( 'should not perform undo on empty past', () => { + const reducer = withHistory( counter ); + + let state; + state = reducer( undefined, {} ); + state = reducer( state, { type: 'INCREMENT' } ); + state = reducer( state, { type: 'UNDO' } ); + + expect( state ).toEqual( { + past: [], + present: 0, + future: [ 1 ], + } ); + } ); + + it( 'should perform redo', () => { + const reducer = withHistory( counter ); + + let state; + state = reducer( undefined, {} ); + state = reducer( state, { type: 'INCREMENT' } ); + state = reducer( state, { type: 'UNDO' } ); + state = reducer( state, { type: 'UNDO' } ); + + expect( state ).toEqual( { + past: [], + present: 0, + future: [ 1 ], + } ); + } ); + + it( 'should not perform redo on empty future', () => { + const reducer = withHistory( counter ); + + let state; + state = reducer( undefined, {} ); + state = reducer( state, { type: 'INCREMENT' } ); + state = reducer( state, { type: 'REDO' } ); + + expect( state ).toEqual( { + past: [ 0 ], + present: 1, + future: [], + } ); + } ); + + it( 'should reset history by options.resetTypes', () => { + const reducer = withHistory( counter, { resetTypes: [ 'RESET_HISTORY' ] } ); + + let state; + state = reducer( undefined, {} ); + state = reducer( state, { type: 'INCREMENT' } ); + state = reducer( state, { type: 'RESET_HISTORY' } ); + state = reducer( state, { type: 'INCREMENT' } ); + state = reducer( state, { type: 'INCREMENT' } ); + + expect( state ).toEqual( { + past: [ 1, 2 ], + present: 3, + future: [], + } ); + } ); + + it( 'should return same reference if state has not changed', () => { + const reducer = withHistory( counter ); + const original = reducer( undefined, {} ); + const state = reducer( original, {} ); + + expect( state ).toBe( original ); + } ); +} );