From 810988b41e67fc164c26ff5e466db3d4fc7a5423 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Mon, 24 Jul 2017 17:12:45 -0400 Subject: [PATCH] Perform dirty detection as diff against saved post --- editor/actions.js | 27 +- editor/autosave-monitor/index.js | 52 ++++ editor/effects.js | 34 +-- editor/index.js | 10 +- editor/layout/index.js | 2 + editor/modes/text-editor/index.js | 64 ++--- editor/selectors.js | 70 ++++- editor/state.js | 82 +++--- editor/test/selectors.js | 444 ++++++++++++++++++++++++------ editor/test/state.js | 106 ++----- 10 files changed, 591 insertions(+), 300 deletions(-) create mode 100644 editor/autosave-monitor/index.js diff --git a/editor/actions.js b/editor/actions.js index aab42330514371..382016cc0bf572 100644 --- a/editor/actions.js +++ b/editor/actions.js @@ -4,6 +4,21 @@ import uuid from 'uuid/v4'; import { partial } from 'lodash'; +/** + * 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 with the * specified UID has been updated. @@ -121,18 +136,6 @@ export function autosave() { }; } -/** - * Returns an action object used in signalling that the post should be queued - * for autosave after a delay. - * - * @return {Object} Action object - */ -export function queueAutosave() { - return { - type: 'QUEUE_AUTOSAVE', - }; -} - /** * Returns an action object used in signalling that undo history should * restore last popped state. diff --git a/editor/autosave-monitor/index.js b/editor/autosave-monitor/index.js new file mode 100644 index 00000000000000..06c0564ce3c905 --- /dev/null +++ b/editor/autosave-monitor/index.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { connect } from 'react-redux'; +import { debounce } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { autosave } from '../actions'; +import { isEditedPostDirty } from '../selectors'; + +class AutosaveMonitor extends Component { + constructor() { + super( ...arguments ); + + this.debouncedAutosave = debounce( () => { + this.props.autosave(); + }, 10000 ); + } + + componentDidUpdate( prevProps ) { + const { isDirty } = this.props; + if ( prevProps.isDirty === isDirty ) { + return; + } + + if ( isDirty ) { + this.debouncedAutosave(); + } else { + this.debouncedAutosave.cancel(); + } + } + + render() { + return null; + } +} + +export default connect( + ( state ) => { + return { + isDirty: isEditedPostDirty( state ), + }; + }, + { autosave } +)( AutosaveMonitor ); diff --git a/editor/effects.js b/editor/effects.js index 327ccd2b3db567..d2aee36808a313 100644 --- a/editor/effects.js +++ b/editor/effects.js @@ -2,12 +2,12 @@ * External dependencies */ import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; -import { get, uniqueId, debounce } from 'lodash'; +import { get, uniqueId } from 'lodash'; /** * WordPress dependencies */ -import { serialize, getBlockType, switchToBlockType } from '@wordpress/blocks'; +import { parse, getBlockType, switchToBlockType } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; /** @@ -15,19 +15,18 @@ import { __ } from '@wordpress/i18n'; */ import { getGutenbergURL, getWPAdminURL } from './utils/url'; import { + resetBlocks, focusBlock, replaceBlocks, createSuccessNotice, createErrorNotice, - autosave, - queueAutosave, savePost, editPost, } from './actions'; import { getCurrentPost, getCurrentPostType, - getBlocks, + getEditedPostContent, getPostEdits, isCurrentPostPublished, isEditedPostDirty, @@ -43,19 +42,15 @@ export default { const edits = getPostEdits( state ); const toSend = { ...edits, - content: serialize( getBlocks( state ) ), + content: getEditedPostContent( state ), id: post.id, }; const transactionId = uniqueId(); - dispatch( { - type: 'CLEAR_POST_EDITS', - optimist: { type: BEGIN, id: transactionId }, - } ); dispatch( { type: 'UPDATE_POST', edits: toSend, - optimist: { id: transactionId }, + optimist: { type: BEGIN, id: transactionId }, } ); const Model = wp.api.getPostTypeModel( getCurrentPostType( state ) ); new Model( toSend ).save().done( ( newPost ) => { @@ -243,15 +238,10 @@ export default { dispatch( savePost() ); }, - QUEUE_AUTOSAVE: debounce( ( action, store ) => { - store.dispatch( autosave() ); - }, 10000 ), - UPDATE_BLOCK_ATTRIBUTES: () => queueAutosave(), - INSERT_BLOCKS: () => queueAutosave(), - MOVE_BLOCKS_DOWN: () => queueAutosave(), - MOVE_BLOCKS_UP: () => queueAutosave(), - REPLACE_BLOCKS: () => queueAutosave(), - REMOVE_BLOCKS: () => queueAutosave(), - EDIT_POST: () => queueAutosave(), - MARK_DIRTY: () => queueAutosave(), + RESET_POST( action ) { + const { post } = action; + if ( post.content ) { + return resetBlocks( parse( post.content.raw ) ); + } + }, }; diff --git a/editor/index.js b/editor/index.js index 2fe956712646cc..f40e55cad20f95 100644 --- a/editor/index.js +++ b/editor/index.js @@ -10,7 +10,7 @@ import 'moment-timezone/moment-timezone-utils'; /** * WordPress dependencies */ -import { EditableProvider, parse } from '@wordpress/blocks'; +import { EditableProvider } from '@wordpress/blocks'; import { render } from '@wordpress/element'; import { settings } from '@wordpress/date'; @@ -65,14 +65,6 @@ function preparePostState( store, post ) { post, } ); - // Parse content as blocks - if ( post.content.raw ) { - store.dispatch( { - type: 'RESET_BLOCKS', - blocks: parse( post.content.raw ), - } ); - } - // Include auto draft title in edits while not flagging post as dirty if ( post.status === 'auto-draft' ) { store.dispatch( { diff --git a/editor/layout/index.js b/editor/layout/index.js index c0685be6cd0fb0..b9d91011e41386 100644 --- a/editor/layout/index.js +++ b/editor/layout/index.js @@ -19,6 +19,7 @@ import TextEditor from '../modes/text-editor'; import VisualEditor from '../modes/visual-editor'; import UnsavedChangesWarning from '../unsaved-changes-warning'; import DocumentTitle from '../document-title'; +import AutosaveMonitor from '../autosave-monitor'; import { removeNotice } from '../actions'; import { getEditorMode, @@ -36,6 +37,7 @@ function Layout( { mode, isSidebarOpened, notices, ...props } ) { +
{ mode === 'text' && } diff --git a/editor/modes/text-editor/index.js b/editor/modes/text-editor/index.js index cd93162555c071..1961ae5c8f1b5e 100644 --- a/editor/modes/text-editor/index.js +++ b/editor/modes/text-editor/index.js @@ -8,60 +8,45 @@ import Textarea from 'react-autosize-textarea'; * WordPress dependencies */ import { Component } from '@wordpress/element'; -import { serialize, parse } from '@wordpress/blocks'; +import { parse } from '@wordpress/blocks'; /** * Internal dependencies */ import './style.scss'; import PostTitle from '../../post-title'; -import { getBlocks } from '../../selectors'; +import { getEditedPostContent } from '../../selectors'; +import { editPost, resetBlocks } from '../../actions'; class TextEditor extends Component { - constructor( { blocks } ) { + constructor( props ) { super( ...arguments ); - const value = serialize( blocks ); + + this.onChange = this.onChange.bind( this ); + this.onPersist = this.onPersist.bind( this ); + this.state = { - blocks, - persistedValue: value, - value, + initialValue: props.value, }; - this.onChange = this.onChange.bind( this ); - this.onBlur = this.onBlur.bind( this ); } onChange( event ) { - this.setState( { - value: event.target.value, - } ); - this.props.markDirty(); + this.props.onChange( event.target.value ); } - onBlur() { - if ( this.state.value === this.state.persistedValue ) { - return; - } - const blocks = parse( this.state.value ); - this.setState( { - blocks, - } ); - this.props.onChange( blocks ); - this.props.markDirty(); - } + onPersist( event ) { + const { value } = event.target; + if ( value !== this.state.initialValue ) { + this.props.onPersist( value ); - componentWillReceiveProps( newProps ) { - if ( newProps.blocks !== this.state.blocks ) { - const value = serialize( newProps.blocks ); this.setState( { - blocks: newProps.blocks, - persistedValue: value, - value, + initialValue: value, } ); } } render() { - const { value } = this.state; + const { value } = this.props; return (
@@ -88,7 +73,7 @@ class TextEditor extends Component { autoComplete="off" value={ value } onChange={ this.onChange } - onBlur={ this.onBlur } + onBlur={ this.onPersist } className="editor-text-editor__textarea" />
@@ -99,19 +84,14 @@ class TextEditor extends Component { export default connect( ( state ) => ( { - blocks: getBlocks( state ), + value: getEditedPostContent( state ), } ), { - onChange( blocks ) { - return { - type: 'RESET_BLOCKS', - blocks, - }; + onChange( content ) { + return editPost( { content } ); }, - markDirty() { - return { - type: 'MARK_DIRTY', - }; + onPersist( content ) { + return resetBlocks( parse( content ) ); }, } )( TextEditor ); diff --git a/editor/selectors.js b/editor/selectors.js index 053ed074f8068f..c6ca16068e23f0 100644 --- a/editor/selectors.js +++ b/editor/selectors.js @@ -2,13 +2,13 @@ * External dependencies */ import moment from 'moment'; -import { first, last, get, values } from 'lodash'; +import { first, last, values, some, isEqual } from 'lodash'; import createSelector from 'rememo'; /** * WordPress dependencies */ -import { getBlockType } from '@wordpress/blocks'; +import { serialize, getBlockType } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; /** @@ -85,9 +85,38 @@ export function isEditedPostNew( state ) { * @param {Object} state Global application state * @return {Boolean} Whether unsaved values exist */ -export function isEditedPostDirty( state ) { - return state.editor.dirty; -} +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 ( ! hasEditorUndo( state ) ) { + return false; + } + + const { history } = state.editor; + return some( [ + 'blocksByUid', + 'blockOrder', + ], ( key ) => ( + ! isEqual( + history.past[ 0 ][ key ], + history.present[ key ] + ) + ) ); + }, + ( state ) => [ + state.editor, + state.currentPost, + ] +); /** * Returns true if there are no unsaved values for the current edit session and if @@ -212,7 +241,7 @@ export function isEditedPostPublishable( state ) { */ export function isEditedPostSaveable( state ) { return ( - getBlockCount( state ) > 0 || + !! getEditedPostContent( state ) || !! getEditedPostTitle( state ) || !! getEditedPostExcerpt( state ) ); @@ -243,8 +272,8 @@ export function getEditedPostTitle( state ) { return editedTitle; } const currentPost = getCurrentPost( state ); - if ( currentPost.title && currentPost.title.raw ) { - return currentPost.title.raw; + if ( currentPost.title && currentPost.title ) { + return currentPost.title; } return ''; } @@ -273,7 +302,7 @@ export function getDocumentTitle( state ) { */ export function getEditedPostExcerpt( state ) { return state.editor.edits.excerpt === undefined - ? get( state.currentPost, 'excerpt.raw' ) + ? state.currentPost.excerpt : state.editor.edits.excerpt; } @@ -691,6 +720,29 @@ export function getSuggestedPostFormat( state ) { 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.edits.content, + state.editor.blocksByUid, + state.editor.blockOrder, + ], +); + /** * Returns the user notices array * diff --git a/editor/state.js b/editor/state.js index bfee2a272e306d..c68a718516d9cb 100644 --- a/editor/state.js +++ b/editor/state.js @@ -4,7 +4,7 @@ import optimist from 'redux-optimist'; import { combineReducers, applyMiddleware, createStore } from 'redux'; import refx from 'refx'; -import { reduce, keyBy, first, last, omit, without, flowRight, forOwn } from 'lodash'; +import { reduce, keyBy, first, last, omit, without, flowRight, mapValues } from 'lodash'; /** * WordPress dependencies @@ -18,7 +18,21 @@ import { combineUndoableReducers } from './utils/undoable-reducer'; import effects from './effects'; const isMobile = window.innerWidth < 782; -const renderedPostProps = new Set( [ 'guid', 'title', 'excerpt', 'content' ] ); + +/** + * 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 ( 'object' === typeof value && 'raw' in value ) { + return value.raw; + } + + return value; +} /** * Undoable reducer returning the editor post state, including blocks parsed @@ -54,34 +68,26 @@ export const editor = combineUndoableReducers( { return result; }, state ); - case 'CLEAR_POST_EDITS': - // Don't return a new object if there's not any edits - if ( ! Object.keys( state ).length ) { - return state; + case 'RESET_BLOCKS': + if ( 'content' in state ) { + return omit( state, 'content' ); } - return {}; - } + return state; - return state; - }, + case 'RESET_POST': + return reduce( state, ( result, value, key ) => { + if ( value !== getPostRawValue( action.post[ key ] ) ) { + return result; + } - dirty( state = false, action ) { - switch ( action.type ) { - case 'RESET_BLOCKS': - case 'REQUEST_POST_UPDATE_SUCCESS': - case 'TRASH_POST_SUCCESS': - return false; + if ( state === result ) { + result = { ...state }; + } - case 'UPDATE_BLOCK_ATTRIBUTES': - case 'INSERT_BLOCKS': - case 'MOVE_BLOCKS_DOWN': - case 'MOVE_BLOCKS_UP': - case 'REPLACE_BLOCKS': - case 'REMOVE_BLOCKS': - case 'EDIT_POST': - case 'MARK_DIRTY': - return true; + delete result[ key ]; + return result; + }, state ); } return state; @@ -226,7 +232,7 @@ export const editor = combineUndoableReducers( { return state; }, -}, { resetTypes: [ 'RESET_BLOCKS' ] } ); +}, { resetTypes: [ 'RESET_POST' ] } ); /** * Reducer loading and saving user specific data, such as preferences and @@ -272,18 +278,20 @@ export const userData = combineReducers( { export function currentPost( state = {}, action ) { switch ( action.type ) { case 'RESET_POST': - return action.post; - case 'UPDATE_POST': - const post = { ...state }; - forOwn( action.edits, ( value, key ) => { - if ( renderedPostProps.has( key ) ) { - post[ key ] = { raw: value }; - } else { - post[ key ] = value; - } - } ); - return 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; diff --git a/editor/test/selectors.js b/editor/test/selectors.js index 4a64048dab030c..9562dc7670aa83 100644 --- a/editor/test/selectors.js +++ b/editor/test/selectors.js @@ -7,6 +7,7 @@ import moment from 'moment'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; /** * Internal dependencies @@ -61,6 +62,30 @@ 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, + } ); + } ); + + beforeEach( () => { + isEditedPostDirty.memoizedSelector.clear(); + } ); + + afterAll( () => { + unregisterBlockType( 'core/test-block' ); + } ); + describe( 'getEditorMode', () => { it( 'should return the selected editor mode', () => { const state = { @@ -174,22 +199,257 @@ describe( 'selectors', () => { } ); describe( 'isEditedPostDirty', () => { - it( 'should return true when the post is dirty', () => { + it( 'should return true when the post has edited attributes', () => { const state = { - editor: { - dirty: true, - }, + currentPost: { + title: '', + }, + editor: getEditorState( [ + { + edits: { + title: 'The Meat Eater\'s Guide to Delicious Meats', + }, + blocksByUid: {}, + blockOrder: [], + }, + ] ), }; expect( isEditedPostDirty( state ) ).toBe( true ); } ); - it( 'should return false when the post is not dirty', () => { + it( 'should return false when the post has no edited attributes and no past', () => { const state = { - editor: { - dirty: false, - }, - currentPost: {}, + currentPost: { + title: 'The Meat Eater\'s Guide to Delicious Meats', + }, + editor: getEditorState( [ + { + edits: { + title: 'The Meat Eater\'s Guide to Delicious Meats', + }, + blocksByUid: {}, + blockOrder: [], + }, + ] ), + }; + + 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: [], + }, + ] ), + }; + + expect( isEditedPostDirty( state ) ).toBe( false ); + } ); + + it( 'should return true when the post has edited block attributes', () => { + 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, + ], + }, + ] ), + }; + + 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, + ], + }, + ] ), + }; + + 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: 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, + ], + }, + ] ), + }; + + expect( isEditedPostDirty( state ) ).toBe( true ); + } ); + + it( 'should return false when no edits, no changed block attributes, no changed order', () => { + 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 }, + }, + 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, + ], + }, + ] ), }; expect( isEditedPostDirty( state ) ).toBe( false ); @@ -199,10 +459,11 @@ describe( 'selectors', () => { describe( 'isCleanNewPost', () => { it( 'should return true when the post is not dirty and has not been saved before', () => { const state = { - editor: { - dirty: false, - edits: {}, - }, + editor: getEditorState( [ + { + edits: {}, + }, + ] ), currentPost: { id: 1, status: 'auto-draft', @@ -214,10 +475,11 @@ describe( 'selectors', () => { it( 'should return false when the post is not dirty but the post has been saved', () => { const state = { - editor: { - dirty: false, - edits: {}, - }, + editor: getEditorState( [ + { + edits: {}, + }, + ] ), currentPost: { id: 1, status: 'draft', @@ -229,10 +491,14 @@ describe( 'selectors', () => { it( 'should return false when the post is dirty but the post has not been saved', () => { const state = { - editor: { - dirty: true, - edits: {}, - }, + editor: getEditorState( [ + { + edits: {}, + }, + { + edits: { title: 'Dirty' }, + }, + ] ), currentPost: { id: 1, status: 'auto-draft', @@ -299,7 +565,7 @@ describe( 'selectors', () => { it( 'should return the post saved title if the title is not edited', () => { const state = { currentPost: { - title: { raw: 'sassel' }, + title: 'sassel', }, editor: { edits: { status: 'private' }, @@ -312,7 +578,7 @@ describe( 'selectors', () => { it( 'should return the edited title', () => { const state = { currentPost: { - title: { raw: 'sassel' }, + title: 'sassel', }, editor: { edits: { title: 'youcha' }, @@ -328,12 +594,13 @@ describe( 'selectors', () => { const state = { currentPost: { id: 123, - title: { raw: 'The Title' }, - }, - editor: { - dirty: false, - edits: {}, + title: 'The Title', }, + editor: getEditorState( [ + { + edits: {}, + }, + ] ), }; expect( getDocumentTitle( state ) ).toBe( 'The Title' ); @@ -343,12 +610,16 @@ describe( 'selectors', () => { const state = { currentPost: { id: 123, - title: { raw: 'The Title' }, - }, - editor: { - dirty: true, - edits: { title: 'Modified Title' }, + title: 'The Title', }, + editor: getEditorState( [ + { + edits: {}, + }, + { + edits: { title: 'Modified Title' }, + }, + ] ), }; expect( getDocumentTitle( state ) ).toBe( 'Modified Title' ); @@ -359,44 +630,30 @@ describe( 'selectors', () => { currentPost: { id: 1, status: 'auto-draft', - title: { raw: '' }, - }, - editor: { - dirty: false, - edits: {}, + title: '', }, + editor: getEditorState( [ + { + edits: {}, + }, + ] ), }; expect( getDocumentTitle( state ) ).toBe( __( 'New post' ) ); } ); - it( 'should return untitled title when new post is dirty', () => { - const state = { - currentPost: { - id: 1, - status: 'auto-draft', - title: { raw: '' }, - }, - editor: { - dirty: true, - edits: {}, - }, - }; - - expect( getDocumentTitle( state ) ).toBe( __( '(Untitled)' ) ); - } ); - it( 'should return untitled title', () => { const state = { currentPost: { id: 123, status: 'draft', - title: { raw: '' }, - }, - editor: { - dirty: true, - edits: {}, + title: '', }, + editor: getEditorState( [ + { + edits: {}, + }, + ] ), }; expect( getDocumentTitle( state ) ).toBe( __( '(Untitled)' ) ); @@ -407,7 +664,7 @@ describe( 'selectors', () => { it( 'should return the post saved excerpt if the excerpt is not edited', () => { const state = { currentPost: { - excerpt: { raw: 'sassel' }, + excerpt: 'sassel', }, editor: { edits: { status: 'private' }, @@ -420,7 +677,7 @@ describe( 'selectors', () => { it( 'should return the edited excerpt', () => { const state = { currentPost: { - excerpt: { raw: 'sassel' }, + excerpt: 'sassel', }, editor: { edits: { excerpt: 'youcha' }, @@ -539,9 +796,11 @@ describe( 'selectors', () => { currentPost: { status: 'pending', }, - editor: { - dirty: false, - }, + editor: getEditorState( [ + { + edits: {}, + }, + ] ), }; expect( isEditedPostPublishable( state ) ).toBe( true ); @@ -552,9 +811,11 @@ describe( 'selectors', () => { currentPost: { status: 'draft', }, - editor: { - dirty: false, - }, + editor: getEditorState( [ + { + edits: {}, + }, + ] ), }; expect( isEditedPostPublishable( state ) ).toBe( true ); @@ -565,9 +826,11 @@ describe( 'selectors', () => { currentPost: { status: 'publish', }, - editor: { - dirty: false, - }, + editor: getEditorState( [ + { + edits: {}, + }, + ] ), }; expect( isEditedPostPublishable( state ) ).toBe( false ); @@ -578,9 +841,11 @@ describe( 'selectors', () => { currentPost: { status: 'private', }, - editor: { - dirty: false, - }, + editor: getEditorState( [ + { + edits: {}, + }, + ] ), }; expect( isEditedPostPublishable( state ) ).toBe( false ); @@ -591,22 +856,29 @@ describe( 'selectors', () => { currentPost: { status: 'private', }, - editor: { - dirty: false, - }, + editor: getEditorState( [ + { + edits: {}, + }, + ] ), }; expect( isEditedPostPublishable( state ) ).toBe( false ); } ); - it( 'should return true for dirty posts', () => { + it( 'should return true for dirty posts with usable title', () => { const state = { currentPost: { status: 'private', }, - editor: { - dirty: true, - }, + editor: getEditorState( [ + { + edits: {}, + }, + { + edits: { title: 'Dirty' }, + }, + ] ), }; expect( isEditedPostPublishable( state ) ).toBe( true ); @@ -635,7 +907,7 @@ describe( 'selectors', () => { edits: {}, }, currentPost: { - title: { raw: 'sassel' }, + title: 'sassel', }, }; @@ -650,7 +922,7 @@ describe( 'selectors', () => { edits: {}, }, currentPost: { - excerpt: { raw: 'sassel' }, + excerpt: 'sassel', }, }; @@ -661,7 +933,13 @@ describe( 'selectors', () => { const state = { editor: { blocksByUid: { - 123: { uid: 123, name: 'core/paragraph' }, + 123: { + uid: 123, + name: 'core/test-block', + attributes: { + text: '', + }, + }, }, blockOrder: [ 123 ], edits: {}, diff --git a/editor/test/state.js b/editor/test/state.js index 2ddec19b4999e9..83a6c30ec41466 100644 --- a/editor/test/state.js +++ b/editor/test/state.js @@ -13,6 +13,7 @@ import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; * Internal dependencies */ import { + getPostRawValue, editor, currentPost, hoveredBlock, @@ -29,6 +30,20 @@ import { } from '../state'; 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', { @@ -397,23 +412,6 @@ describe( 'state', () => { } ); } ); - it( 'should reset modified properties', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: { - status: 'draft', - title: 'post title', - tags: [ 1 ], - }, - } ); - - const state = editor( original, { - type: 'CLEAR_POST_EDITS', - } ); - - expect( state.edits ).toEqual( {} ); - } ); - it( 'should return same reference if clearing non-edited', () => { const original = editor( undefined, { type: 'EDIT_POST', @@ -443,70 +441,6 @@ describe( 'state', () => { } ); } ); - describe( 'dirty()', () => { - it( 'should be true when the post is edited', () => { - const state = editor( undefined, { - type: 'EDIT_POST', - edits: {}, - } ); - - expect( state.dirty ).toBe( true ); - } ); - - it( 'should change to false when the post is reset', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: {}, - } ); - - const state = editor( original, { - type: 'RESET_BLOCKS', - post: {}, - blocks: [], - } ); - - expect( state.dirty ).toBe( false ); - } ); - - it( 'should not change from true when an unrelated action occurs', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: {}, - } ); - - const state = editor( original, { - type: 'BRISKET_READY', - } ); - - expect( state.dirty ).toBe( true ); - } ); - - it( 'should not change from false when an unrelated action occurs', () => { - const original = editor( undefined, { - type: 'RESET_BLOCKS', - post: {}, - blocks: [], - } ); - - expect( original.dirty ).toBe( false ); - - const state = editor( original, { - type: 'BRISKET_READY', - } ); - - expect( state.dirty ).toBe( false ); - } ); - - it( 'should be false when the post is initialized', () => { - const state = editor( undefined, { - type: 'SETUP_NEW_POST', - edits: {}, - } ); - - expect( state.dirty ).toBe( false ); - } ); - } ); - describe( 'blocksByUid', () => { it( 'should return with attribute block updates', () => { const original = deepFreeze( editor( undefined, { @@ -592,22 +526,22 @@ describe( 'state', () => { describe( 'currentPost()', () => { it( 'should reset a post object', () => { - const original = deepFreeze( { title: { raw: 'unmodified' } } ); + const original = deepFreeze( { title: 'unmodified' } ); const state = currentPost( original, { type: 'RESET_POST', post: { - title: { raw: 'new post' }, + title: 'new post', }, } ); expect( state ).toEqual( { - title: { raw: 'new post' }, + title: 'new post', } ); } ); it( 'should update the post object with UPDATE_POST', () => { - const original = deepFreeze( { title: { raw: 'unmodified' }, status: 'publish' } ); + const original = deepFreeze( { title: 'unmodified', status: 'publish' } ); const state = currentPost( original, { type: 'UPDATE_POST', @@ -617,7 +551,7 @@ describe( 'state', () => { } ); expect( state ).toEqual( { - title: { raw: 'updated post object from server' }, + title: 'updated post object from server', status: 'publish', } ); } );