diff --git a/editor/store/actions.js b/editor/store/actions.js index 6453c6c700292..fb9aa2a6357b8 100644 --- a/editor/store/actions.js +++ b/editor/store/actions.js @@ -539,5 +539,8 @@ export function convertBlockToReusable( uid ) { export function insertDefaultBlock( attributes, rootUID, index ) { const block = createBlock( getDefaultBlockName(), attributes ); - return insertBlock( block, index, rootUID ); + return { + ...insertBlock( block, index, rootUID ), + isProvisional: true, + }; } diff --git a/editor/store/effects.js b/editor/store/effects.js index 545c6c9bb5e66..c336868b36eaa 100644 --- a/editor/store/effects.js +++ b/editor/store/effects.js @@ -52,6 +52,7 @@ import { getBlocks, getReusableBlock, getProvisionalBlockUID, + isBlockSelected, POST_UPDATE_TRANSACTION_ID, } from './selectors'; @@ -72,8 +73,9 @@ const REUSABLE_BLOCK_NOTICE_ID = 'REUSABLE_BLOCK_NOTICE_ID'; * @return {?Object} Remove action, if provisional block is set. */ export function removeProvisionalBlock( action, store ) { - const provisionalBlockUID = getProvisionalBlockUID( store.getState() ); - if ( provisionalBlockUID ) { + const state = store.getState(); + const provisionalBlockUID = getProvisionalBlockUID( state ); + if ( provisionalBlockUID && ! isBlockSelected( state, provisionalBlockUID ) ) { return removeBlock( provisionalBlockUID ); } } diff --git a/editor/store/reducer.js b/editor/store/reducer.js index 5b03ccddd6382..92923cd16b310 100644 --- a/editor/store/reducer.js +++ b/editor/store/reducer.js @@ -16,6 +16,7 @@ import { omitBy, keys, isEqual, + includes, } from 'lodash'; /** @@ -600,6 +601,48 @@ export function blockSelection( state = { return state; } +/** + * Reducer returning the UID of the provisional block. A provisional block is + * one which is to be removed if it does not receive updates in the time until + * the next selection or block reset. + * + * @param {string} state Current state. + * @param {Object} action Dispatched action. + * + * @return {string} Updated state. + */ +export function provisionalBlockUID( state = null, action ) { + switch ( action.type ) { + case 'INSERT_BLOCKS': + if ( action.isProvisional ) { + return first( action.blocks ).uid; + } + break; + + case 'RESET_BLOCKS': + return null; + + case 'UPDATE_BLOCK_ATTRIBUTES': + case 'UPDATE_BLOCK': + case 'CONVERT_BLOCK_TO_REUSABLE': + const { uid } = action; + if ( uid === state ) { + return null; + } + break; + + case 'REPLACE_BLOCKS': + case 'REMOVE_BLOCKS': + const { uids } = action; + if ( includes( uids, state ) ) { + return null; + } + break; + } + + return state; +} + export function blocksMode( state = {}, action ) { if ( action.type === 'TOGGLE_BLOCK_MODE' ) { const { uid } = action; @@ -845,6 +888,7 @@ export default optimist( combineReducers( { currentPost, isTyping, blockSelection, + provisionalBlockUID, blocksMode, isInsertionPointVisible, preferences, diff --git a/editor/store/selectors.js b/editor/store/selectors.js index 14ce95bed18fb..e864360e04309 100644 --- a/editor/store/selectors.js +++ b/editor/store/selectors.js @@ -1296,3 +1296,14 @@ export function isPublishingPost( state ) { // considered published return !! stateBeforeRequest && ! isCurrentPostPublished( stateBeforeRequest ); } + +/** + * Returns the provisional block UID, or null if there is no provisional block. + * + * @param {Object} state Editor state. + * + * @return {?string} Provisional block UID, if set. + */ +export function getProvisionalBlockUID( state ) { + return state.provisionalBlockUID; +} diff --git a/editor/store/test/effects.js b/editor/store/test/effects.js index afa245777567d..3c010e3c13ed8 100644 --- a/editor/store/test/effects.js +++ b/editor/store/test/effects.js @@ -30,9 +30,12 @@ import { convertBlockToStatic, convertBlockToReusable, selectBlock, + removeBlock, } from '../../store/actions'; import reducer from '../reducer'; -import effects from '../effects'; +import effects, { + removeProvisionalBlock, +} from '../effects'; import * as selectors from '../../store/selectors'; // Make all generated UUIDs the same for testing @@ -43,6 +46,47 @@ jest.mock( 'uuid/v4', () => { describe( 'effects', () => { const defaultBlockSettings = { save: () => 'Saved', category: 'common', title: 'block title' }; + describe( 'removeProvisionalBlock()', () => { + const store = { getState: () => {} }; + + beforeAll( () => { + selectors.getProvisionalBlockUID = jest.spyOn( selectors, 'getProvisionalBlockUID' ); + selectors.isBlockSelected = jest.spyOn( selectors, 'isBlockSelected' ); + } ); + + beforeEach( () => { + selectors.getProvisionalBlockUID.mockReset(); + selectors.isBlockSelected.mockReset(); + } ); + + afterAll( () => { + selectors.getProvisionalBlockUID.mockRestore(); + selectors.isBlockSelected.mockRestore(); + } ); + + it( 'should return nothing if there is no provisional block', () => { + const action = removeProvisionalBlock( {}, store ); + + expect( action ).toBeUndefined(); + } ); + + it( 'should return nothing if there is a provisional block and it is selected', () => { + selectors.getProvisionalBlockUID.mockReturnValue( 'chicken' ); + selectors.isBlockSelected.mockImplementation( ( state, uid ) => uid === 'chicken' ); + const action = removeProvisionalBlock( {}, store ); + + expect( action ).toBeUndefined(); + } ); + + it( 'should return remove action for provisional block', () => { + selectors.getProvisionalBlockUID.mockReturnValue( 'chicken' ); + selectors.isBlockSelected.mockImplementation( ( state, uid ) => uid === 'ribs' ); + const action = removeProvisionalBlock( {}, store ); + + expect( action ).toEqual( removeBlock( 'chicken' ) ); + } ); + } ); + describe( '.MERGE_BLOCKS', () => { const handler = effects.MERGE_BLOCKS; const defaultGetBlock = selectors.getBlock; diff --git a/editor/store/test/reducer.js b/editor/store/test/reducer.js index 81df4129511e2..f0b229f4dbb9b 100644 --- a/editor/store/test/reducer.js +++ b/editor/store/test/reducer.js @@ -26,6 +26,7 @@ import { preferences, saving, notices, + provisionalBlockUID, blocksMode, isInsertionPointVisible, reusableBlocks, @@ -1417,6 +1418,100 @@ describe( 'state', () => { } ); } ); + describe( 'provisionalBlockUID()', () => { + const PROVISIONAL_UPDATE_ACTION_TYPES = [ + 'UPDATE_BLOCK_ATTRIBUTES', + 'UPDATE_BLOCK', + 'CONVERT_BLOCK_TO_REUSABLE', + ]; + + const PROVISIONAL_REPLACE_ACTION_TYPES = [ + 'REPLACE_BLOCKS', + 'REMOVE_BLOCKS', + ]; + + it( 'returns null by default', () => { + const state = provisionalBlockUID( undefined, {} ); + + expect( state ).toBe( null ); + } ); + + it( 'returns the uid of the first inserted provisional block', () => { + const state = provisionalBlockUID( null, { + type: 'INSERT_BLOCKS', + isProvisional: true, + blocks: [ + { uid: 'chicken' }, + ], + } ); + + expect( state ).toBe( 'chicken' ); + } ); + + it( 'does not return uid of block if not provisional', () => { + const state = provisionalBlockUID( null, { + type: 'INSERT_BLOCKS', + blocks: [ + { uid: 'chicken' }, + ], + } ); + + expect( state ).toBe( null ); + } ); + + it( 'returns null on block reset', () => { + const state = provisionalBlockUID( 'chicken', { + type: 'RESET_BLOCKS', + } ); + + expect( state ).toBe( null ); + } ); + + it( 'returns null on block update', () => { + PROVISIONAL_UPDATE_ACTION_TYPES.forEach( ( type ) => { + const state = provisionalBlockUID( 'chicken', { + type, + uid: 'chicken', + } ); + + expect( state ).toBe( null ); + } ); + } ); + + it( 'does not return null if update occurs to non-provisional block', () => { + PROVISIONAL_UPDATE_ACTION_TYPES.forEach( ( type ) => { + const state = provisionalBlockUID( 'chicken', { + type, + uid: 'ribs', + } ); + + expect( state ).toBe( 'chicken' ); + } ); + } ); + + it( 'returns null if replacement of provisional block', () => { + PROVISIONAL_REPLACE_ACTION_TYPES.forEach( ( type ) => { + const state = provisionalBlockUID( 'chicken', { + type, + uids: [ 'chicken' ], + } ); + + expect( state ).toBe( null ); + } ); + } ); + + it( 'does not return null if replacement of non-provisional block', () => { + PROVISIONAL_REPLACE_ACTION_TYPES.forEach( ( type ) => { + const state = provisionalBlockUID( 'chicken', { + type, + uids: [ 'ribs' ], + } ); + + expect( state ).toBe( 'chicken' ); + } ); + } ); + } ); + describe( 'blocksMode', () => { it( 'should set mode to html if not set', () => { const action = { diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js index 9cb79e54dc334..05eb53e6b84d1 100644 --- a/editor/store/test/selectors.js +++ b/editor/store/test/selectors.js @@ -73,6 +73,7 @@ const { getInserterItems, getRecentInserterItems, getFrequentInserterItems, + getProvisionalBlockUID, POST_UPDATE_TRANSACTION_ID, } = selectors; @@ -2779,4 +2780,22 @@ describe( 'selectors', () => { expect( isPublishing ).toBe( true ); } ); } ); + + describe( 'getProvisionalBlockUID()', () => { + it( 'should return null if not set', () => { + const provisionalBlockUID = getProvisionalBlockUID( { + provisionalBlockUID: null, + } ); + + expect( provisionalBlockUID ).toBe( null ); + } ); + + it( 'should return UID of provisional block', () => { + const provisionalBlockUID = getProvisionalBlockUID( { + provisionalBlockUID: 'chicken', + } ); + + expect( provisionalBlockUID ).toBe( 'chicken' ); + } ); + } ); } ); diff --git a/test/e2e/integration/002-adding-blocks.js b/test/e2e/integration/002-adding-blocks.js index 1f899413a941b..1b948d7faf02a 100644 --- a/test/e2e/integration/002-adding-blocks.js +++ b/test/e2e/integration/002-adding-blocks.js @@ -4,20 +4,22 @@ describe( 'Adding blocks', () => { } ); it( 'Should insert content using the placeholder and the regular inserter', () => { - const lastBlockSelector = '.editor-block-list__block-edit:last [contenteditable="true"]:first'; + const lastBlockSelector = '.editor-block-list__block-edit:last'; // Using the placeholder cy.get( '.editor-default-block-appender' ).click(); - cy.get( lastBlockSelector ).type( 'Paragraph block' ); + cy.focused().type( 'Paragraph block' ); + + // Default block appender is provisional + cy.get( lastBlockSelector ).then( ( firstBlock ) => { + cy.get( '.editor-default-block-appender' ).click(); + cy.get( '.edit-post-visual-editor' ).click(); + cy.get( lastBlockSelector ).should( 'have.text', firstBlock.text() ); + } ); // Using the slash command - // Test commented because Cypress is not update the selection object properly, - // so the slash inserter is not showing up. - /* cy.get( '.edit-post-header [aria-label="Add block"]' ).click(); - cy.get( '[placeholder="Search for a block"]' ).type( 'Paragraph' ); - cy.get( '.editor-inserter__block' ).contains( 'Paragraph' ).click(); - cy.get( lastBlockSelector ).type( '/quote{enter}' ); - cy.get( lastBlockSelector ).type( 'Quote block' ); */ + // TODO: Test omitted because Cypress doesn't update the selection + // object properly, so the slash inserter is not showing up. // Using the regular inserter cy.get( '.edit-post-header [aria-label="Add block"]' ).click(); @@ -25,14 +27,29 @@ describe( 'Adding blocks', () => { cy.get( '.editor-inserter__block' ).contains( 'Code' ).click(); cy.get( '[placeholder="Write codeā€¦"]' ).type( 'Code block' ); + // Using the between inserter + cy.document().trigger( 'mousemove', { clientX: 200, clientY: 300 } ); + cy.document().trigger( 'mousemove', { clientX: 250, clientY: 350 } ); + cy.get( '[data-type="core/paragraph"] .editor-block-list__insertion-point-inserter' ).click(); + cy.focused().type( 'Second paragraph' ); + // Switch to Text Mode to check HTML Output cy.get( '.edit-post-more-menu [aria-label="More"]' ).click(); cy.get( 'button' ).contains( 'Code Editor' ).click(); // Assertions - cy.get( '.edit-post-text-editor' ) - .should( 'contain', 'Paragraph block' ) - // .should( 'contain', 'Quote block' ) - .should( 'contain', 'Code block' ); + cy.get( '.editor-post-text-editor' ).should( 'have.value', [ + '', + '

Paragraph block

', + '', + '', + '', + '

Second paragraph

', + '', + '', + '', + '
Code block
', + '', + ].join( '\n' ) ); } ); } ); diff --git a/test/e2e/integration/004-managing-links.js b/test/e2e/integration/004-managing-links.js index 7c482c5a533a2..dca8a7ef95cf0 100644 --- a/test/e2e/integration/004-managing-links.js +++ b/test/e2e/integration/004-managing-links.js @@ -1,5 +1,5 @@ describe( 'Managing links', () => { - before( () => { + beforeEach( () => { cy.newPost(); } ); @@ -28,6 +28,8 @@ describe( 'Managing links', () => { cy.get( '.editor-default-block-appender' ).click(); + cy.focused().type( 'Text' ); + cy.get( 'button[aria-label="Link"]' ).click(); // Typing "left" should not close the dialog @@ -42,12 +44,12 @@ describe( 'Managing links', () => { it( 'Pressing Left and Esc in Link Dialog in "Docked Toolbar" mode', () => { setFixedToolbar( false ); - const lastBlockSelector = '.editor-block-list__block-edit:last [contenteditable="true"]:first'; + cy.get( '.editor-default-block-appender' ).click(); - cy.get( lastBlockSelector ).click(); - cy.focused().type( 'test' ); + cy.focused().type( 'Text' ); // we need to trigger isTyping = false + const lastBlockSelector = '.editor-block-list__block-edit:last [contenteditable="true"]:first'; cy.get( lastBlockSelector ).trigger( 'mousemove', { clientX: 200, clientY: 300 } ); cy.get( lastBlockSelector ).trigger( 'mousemove', { clientX: 250, clientY: 350 } );