diff --git a/blocks/api/index.js b/blocks/api/index.js index 828e6cce27b31..cc7d47cd0ac11 100644 --- a/blocks/api/index.js +++ b/blocks/api/index.js @@ -33,3 +33,7 @@ export { export { isUnmodifiedDefaultBlock, } from './utils'; +export { + doBlocksMatchTemplate, + synchronizeBlocksWithTemplate, +} from './templates'; diff --git a/blocks/api/templates.js b/blocks/api/templates.js new file mode 100644 index 0000000000000..b0cc2daf1aa96 --- /dev/null +++ b/blocks/api/templates.js @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import { every, map } from 'lodash'; + +/** + * Internal dependencies + */ +import { createBlock } from './factory'; + +/** + * Checks whether a list of blocks matches a template by comparing the block names. + * + * @param {Array} blocks Block list. + * @param {Array} template Block template. + * + * @return {boolean} Whether the list of blocks matches a templates + */ +export function doBlocksMatchTemplate( blocks = [], template = [] ) { + return ( + blocks.length === template.length && + every( template, ( [ name, , innerBlocksTemplate ], index ) => { + const block = blocks[ index ]; + return ( + name === block.name && + doBlocksMatchTemplate( block.innerBlocks, innerBlocksTemplate ) + ); + } ) + ); +} + +/** + * Synchronize a block list with a block template. + * + * Synchronnizing a block list with a block template means that we loop over the blocks + * keep the block as is if it matches the block at the same position in the template + * (If it has the same name) and if doesn't match, we create a new block based on the template. + * Extra blocks not present in the template are removed. + * + * @param {Array} blocks Block list. + * @param {Array} template Block template. + * + * @return {Array} Updated Block list. + */ +export function synchronizeBlocksWithTemplate( blocks = [], template = [] ) { + return map( template, ( [ name, attributes, innerBlocksTemplate ], index ) => { + const block = blocks[ index ]; + + if ( block && block.name === name ) { + const innerBlocks = synchronizeBlocksWithTemplate( block.innerBlocks, innerBlocksTemplate ); + return { ...block, innerBlocks }; + } + + return createBlock( + name, + attributes, + synchronizeBlocksWithTemplate( [], innerBlocksTemplate ) + ); + } ); +} diff --git a/blocks/api/test/templates.js b/blocks/api/test/templates.js new file mode 100644 index 0000000000000..1ee52fdadb756 --- /dev/null +++ b/blocks/api/test/templates.js @@ -0,0 +1,177 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { createBlock } from '../factory'; +import { getBlockTypes, unregisterBlockType, registerBlockType } from '../registration'; +import { doBlocksMatchTemplate, synchronizeBlocksWithTemplate } from '../templates'; + +describe( 'templates', () => { + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + beforeEach( () => { + registerBlockType( 'core/test-block', { + attributes: {}, + save: noop, + category: 'common', + title: 'test block', + } ); + + registerBlockType( 'core/test-block-2', { + attributes: {}, + save: noop, + category: 'common', + title: 'test block', + } ); + } ); + + describe( 'doBlocksMatchTemplate', () => { + it( 'return true if for empty templates and blocks', () => { + expect( doBlocksMatchTemplate() ).toBe( true ); + } ); + + it( 'return true if the template matches the blocks', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2' ], + [ 'core/test-block-2' ], + ]; + const blockList = [ + createBlock( 'core/test-block' ), + createBlock( 'core/test-block-2' ), + createBlock( 'core/test-block-2' ), + ]; + expect( doBlocksMatchTemplate( blockList, template ) ).toBe( true ); + } ); + + it( 'return true if the template matches the blocks with nested blocks', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2', {}, [ + [ 'core/test-block' ], + ] ], + [ 'core/test-block-2' ], + ]; + const blockList = [ + createBlock( 'core/test-block' ), + createBlock( 'core/test-block-2', {}, [ createBlock( 'core/test-block' ) ] ), + createBlock( 'core/test-block-2' ), + ]; + expect( doBlocksMatchTemplate( blockList, template ) ).toBe( true ); + } ); + + it( 'return false if the template length doesn\'t match the blocks length', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2' ], + ]; + const blockList = [ + createBlock( 'core/test-block' ), + createBlock( 'core/test-block-2' ), + createBlock( 'core/test-block-2' ), + ]; + expect( doBlocksMatchTemplate( blockList, template ) ).toBe( false ); + } ); + + it( 'return false if the nested template doesn\'t match the blocks', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2', {}, [ + [ 'core/test-block' ], + ] ], + [ 'core/test-block-2' ], + ]; + const blockList = [ + createBlock( 'core/test-block' ), + createBlock( 'core/test-block-2', {}, [ createBlock( 'core/test-block-2' ) ] ), + createBlock( 'core/test-block-2' ), + ]; + expect( doBlocksMatchTemplate( blockList, template ) ).toBe( false ); + } ); + } ); + + describe( 'synchronizeBlocksWithTemplate', () => { + it( 'should create blocks for each template entry', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2' ], + [ 'core/test-block-2' ], + ]; + const blockList = []; + expect( synchronizeBlocksWithTemplate( blockList, template ) ).toMatchObject( [ + { name: 'core/test-block' }, + { name: 'core/test-block-2' }, + { name: 'core/test-block-2' }, + ] ); + } ); + + it( 'should create nested blocks', () => { + const template = [ + [ 'core/test-block', {}, [ + [ 'core/test-block-2' ], + ] ], + ]; + const blockList = []; + expect( synchronizeBlocksWithTemplate( blockList, template ) ).toMatchObject( [ + { name: 'core/test-block', innerBlocks: [ + { name: 'core/test-block-2' }, + ] }, + ] ); + } ); + + it( 'should append blocks if more blocks in the template', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2' ], + [ 'core/test-block-2' ], + ]; + + const block1 = createBlock( 'core/test-block' ); + const block2 = createBlock( 'core/test-block-2' ); + const blockList = [ block1, block2 ]; + expect( synchronizeBlocksWithTemplate( blockList, template ) ).toMatchObject( [ + block1, + block2, + { name: 'core/test-block-2' }, + ] ); + } ); + + it( 'should replace blocks if not matching blocks are found', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2' ], + [ 'core/test-block-2' ], + ]; + + const block1 = createBlock( 'core/test-block' ); + const block2 = createBlock( 'core/test-block' ); + const blockList = [ block1, block2 ]; + expect( synchronizeBlocksWithTemplate( blockList, template ) ).toMatchObject( [ + block1, + { name: 'core/test-block-2' }, + { name: 'core/test-block-2' }, + ] ); + } ); + + it( 'should remove blocks if extra blocks are found', () => { + const template = [ + [ 'core/test-block' ], + ]; + + const block1 = createBlock( 'core/test-block' ); + const block2 = createBlock( 'core/test-block' ); + const blockList = [ block1, block2 ]; + expect( synchronizeBlocksWithTemplate( blockList, template ) ).toEqual( [ + block1, + ] ); + } ); + } ); +} ); diff --git a/components/notice/index.js b/components/notice/index.js index c2d1a8273400b..e8fa2ca7482a1 100644 --- a/components/notice/index.js +++ b/components/notice/index.js @@ -14,13 +14,13 @@ import { __ } from '@wordpress/i18n'; */ import './style.scss'; -function Notice( { status, content, onRemove = noop, isDismissible = true } ) { - const className = classnames( 'notice notice-alt notice-' + status, { +function Notice( { className, status, children, onRemove = noop, isDismissible = true } ) { + const classNames = classnames( className, 'notice notice-alt notice-' + status, { 'is-dismissible': isDismissible, } ); return ( -
- { isString( content ) ?

{ content }

: content } +
+ { isString( children ) ?

{ children }

: children } { isDismissible && ( + +
+ + ); +} + +export default connect( + ( state ) => ( { + isValid: isValidTemplate( state ), + } ), + { + resetTemplateValidity: () => setTemplateValidity( true ), + synchronizeTemplate, + } +)( TemplateValidationNotice ); diff --git a/editor/components/template-validation-notice/style.scss b/editor/components/template-validation-notice/style.scss new file mode 100644 index 0000000000000..dde63f7e36b14 --- /dev/null +++ b/editor/components/template-validation-notice/style.scss @@ -0,0 +1,9 @@ +.editor-template-validation-notice { + display: flex; + justify-content: space-between; + align-items: center; + + .components-button { + margin-left: 5px; + } +} diff --git a/editor/store/actions.js b/editor/store/actions.js index f28e59255d04e..5b64b55b7146d 100644 --- a/editor/store/actions.js +++ b/editor/store/actions.js @@ -47,9 +47,9 @@ export function resetPost( post ) { /** * Returns an action object used to setup the editor state when first opening an editor. * - * @param {Object} post Post object. - * @param {Array} blocks Array of blocks. - * @param {Object} edits Initial edited attributes object. + * @param {Object} post Post object. + * @param {Array} blocks Array of blocks. + * @param {Object} edits Initial edited attributes object. * * @return {Object} Action object. */ @@ -265,6 +265,42 @@ export function hideInsertionPoint() { }; } +/** + * Returns an action object resetting the template validity. + * + * @param {boolean} isValid template validity flag. + * + * @return {Object} Action object. + */ +export function setTemplateValidity( isValid ) { + return { + type: 'SET_TEMPLATE_VALIDITY', + isValid, + }; +} + +/** + * Returns an action object tocheck the template validity. + * + * @return {Object} Action object. + */ +export function checkTemplateValidity() { + return { + type: 'CHECK_TEMPLATE_VALIDITY', + }; +} + +/** + * Returns an action object synchronize the template with the list of blocks + * + * @return {Object} Action object. + */ +export function synchronizeTemplate() { + return { + type: 'SYNCHRONIZE_TEMPLATE', + }; +} + export function editPost( edits ) { return { type: 'EDIT_POST', diff --git a/editor/store/effects.js b/editor/store/effects.js index a808e80b04007..08d3e2d64c8e9 100644 --- a/editor/store/effects.js +++ b/editor/store/effects.js @@ -15,6 +15,8 @@ import { serialize, isReusableBlock, getDefaultBlockForPostFormat, + doBlocksMatchTemplate, + synchronizeBlocksWithTemplate, } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; import { speak } from '@wordpress/a11y'; @@ -39,6 +41,8 @@ import { removeBlocks, selectBlock, removeBlock, + resetBlocks, + setTemplateValidity, } from './actions'; import { getCurrentPost, @@ -58,7 +62,9 @@ import { getProvisionalBlockUID, getSelectedBlock, isBlockSelected, + getTemplate, POST_UPDATE_TRANSACTION_ID, + getTemplateLock, } from './selectors'; /** @@ -305,19 +311,18 @@ export default { // Parse content as blocks let blocks; + let isValidTemplate = true; if ( post.content.raw ) { blocks = parse( post.content.raw ); + + // Unlocked templates are considered always valid because they act as default values only. + isValidTemplate = ( + ! settings.template || + settings.templateLock !== 'all' || + doBlocksMatchTemplate( blocks, settings.template ) + ); } else if ( settings.template ) { - const createBlocksFromTemplate = ( template ) => { - return map( template, ( [ name, attributes, innerBlocksTemplate ] ) => { - return createBlock( - name, - attributes, - createBlocksFromTemplate( innerBlocksTemplate ) - ); - } ); - }; - blocks = createBlocksFromTemplate( settings.template ); + blocks = synchronizeBlocksWithTemplate( [], settings.template ); } else if ( getDefaultBlockForPostFormat( post.format ) ) { blocks = [ createBlock( getDefaultBlockForPostFormat( post.format ) ) ]; } else { @@ -331,7 +336,34 @@ export default { edits.status = 'draft'; } - return setupEditorState( post, blocks, edits ); + return [ + setTemplateValidity( isValidTemplate ), + setupEditorState( post, blocks, edits ), + ]; + }, + SYNCHRONIZE_TEMPLATE( action, { getState } ) { + const state = getState(); + const blocks = getBlocks( state ); + const template = getTemplate( state ); + const updatedBlockList = synchronizeBlocksWithTemplate( blocks, template ); + + return [ + resetBlocks( updatedBlockList ), + setTemplateValidity( true ), + ]; + }, + CHECK_TEMPLATE_VALIDITY( action, { getState } ) { + const state = getState(); + const blocks = getBlocks( state ); + const template = getTemplate( state ); + const templateLock = getTemplateLock( state ); + const isValid = ( + ! template || + templateLock !== 'all' || + doBlocksMatchTemplate( blocks, template ) + ); + + return setTemplateValidity( isValid ); }, FETCH_REUSABLE_BLOCKS( action, store ) { // TODO: these are potentially undefined, this fix is in place diff --git a/editor/store/reducer.js b/editor/store/reducer.js index a032796968adf..2617b62494661 100644 --- a/editor/store/reducer.js +++ b/editor/store/reducer.js @@ -728,6 +728,32 @@ export function isInsertionPointVisible( state = false, action ) { return state; } +/** + * Reducer returning whether the post blocks match the defined template or not. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function template( state = { isValid: true }, action ) { + switch ( action.type ) { + case 'SETUP_EDITOR': + return { + ...state, + template: action.settings.template, + lock: action.settings.templateLock, + }; + case 'SET_TEMPLATE_VALIDITY': + return { + ...state, + isValid: action.isValid, + }; + } + + return state; +} + /** * Reducer returning the user preferences. * @@ -951,4 +977,5 @@ export default optimist( combineReducers( { saving, notices, reusableBlocks, + template, } ) ); diff --git a/editor/store/selectors.js b/editor/store/selectors.js index e41ef42507448..9a95e21fc9704 100644 --- a/editor/store/selectors.js +++ b/editor/store/selectors.js @@ -966,6 +966,36 @@ export function isBlockInsertionPointVisible( state ) { return state.isInsertionPointVisible; } +/** + * Returns whether the blocks matches the template or not. + * + * @param {boolean} state + * @return {?boolean} Whether the template is valid or not. + */ +export function isValidTemplate( state ) { + return state.template.isValid; +} + +/** + * Returns the defined block template + * + * @param {boolean} state + * @return {?Arary} Block Template + */ +export function getTemplate( state ) { + return state.template.template; +} + +/** + * Returns the defined block template lock + * + * @param {boolean} state + * @return {?string} Block Template Lock + */ +export function getTemplateLock( state ) { + return state.template.lock; +} + /** * Returns true if the post is currently being saved, or false otherwise. * diff --git a/editor/store/test/effects.js b/editor/store/test/effects.js index ba19537427176..e12e801c458d9 100644 --- a/editor/store/test/effects.js +++ b/editor/store/test/effects.js @@ -33,6 +33,7 @@ import { resetBlocks, convertBlockToStatic, convertBlockToReusable, + setTemplateValidity, } from '../actions'; import effects, { removeProvisionalBlock, @@ -520,7 +521,10 @@ describe( 'effects', () => { const result = handler( { post, settings: {} } ); - expect( result ).toEqual( setupEditorState( post, [], {} ) ); + expect( result ).toEqual( [ + setTemplateValidity( true ), + setupEditorState( post, [], {} ), + ] ); } ); it( 'should return block reset with non-empty content', () => { @@ -538,8 +542,11 @@ describe( 'effects', () => { const result = handler( { post, settings: {} } ); - expect( result.blocks ).toHaveLength( 1 ); - expect( result ).toEqual( setupEditorState( post, result.blocks, {} ) ); + expect( result[ 1 ].blocks ).toHaveLength( 1 ); + expect( result ).toEqual( [ + setTemplateValidity( true ), + setupEditorState( post, result[ 1 ].blocks, {} ), + ] ); } ); it( 'should return post setup action only if auto-draft', () => { @@ -556,7 +563,10 @@ describe( 'effects', () => { const result = handler( { post, settings: {} } ); - expect( result ).toEqual( setupEditorState( post, [], { title: 'A History of Pork', status: 'draft' } ) ); + expect( result ).toEqual( [ + setTemplateValidity( true ), + setupEditorState( post, [], { title: 'A History of Pork', status: 'draft' } ), + ] ); } ); } ); diff --git a/editor/store/test/reducer.js b/editor/store/test/reducer.js index 4789158941b0e..d1d59bc6f831b 100644 --- a/editor/store/test/reducer.js +++ b/editor/store/test/reducer.js @@ -34,6 +34,7 @@ import { blocksMode, isInsertionPointVisible, reusableBlocks, + template, } from '../reducer'; describe( 'state', () => { @@ -2022,4 +2023,32 @@ describe( 'state', () => { } ); } ); } ); + + describe( 'template', () => { + it( 'should default to visible', () => { + const state = template( undefined, {} ); + + expect( state ).toEqual( { isValid: true } ); + } ); + + it( 'should set the template', () => { + const blockTemplate = [ [ 'core/paragraph' ] ]; + const state = template( undefined, { + type: 'SETUP_EDITOR', + settings: { template: blockTemplate, templateLock: 'all' }, + } ); + + expect( state ).toEqual( { isValid: true, template: blockTemplate, lock: 'all' } ); + } ); + + it( 'should reset the validity flag', () => { + const original = deepFreeze( { isValid: false, template: [] } ); + const state = template( original, { + type: 'SET_TEMPLATE_VALIDITY', + isValid: true, + } ); + + expect( state ).toEqual( { isValid: true, template: [] } ); + } ); + } ); } ); diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js index 99923ebbc92b4..9eba56e7bbf2f 100644 --- a/editor/store/test/selectors.js +++ b/editor/store/test/selectors.js @@ -75,6 +75,9 @@ const { getInserterItems, getFrecentInserterItems, getProvisionalBlockUID, + isValidTemplate, + getTemplate, + getTemplateLock, POST_UPDATE_TRANSACTION_ID, } = selectors; @@ -2848,4 +2851,43 @@ describe( 'selectors', () => { expect( provisionalBlockUID ).toBe( 'chicken' ); } ); } ); + + describe( 'isValidTemplate', () => { + it( 'should return true if template is valid', () => { + const state = { + template: { isValid: true }, + }; + + expect( isValidTemplate( state ) ).toBe( true ); + } ); + + it( 'should return false if template is not valid', () => { + const state = { + template: { isValid: false }, + }; + + expect( isValidTemplate( state ) ).toBe( false ); + } ); + } ); + + describe( 'getTemplate', () => { + it( 'should return the template object', () => { + const template = []; + const state = { + template: { isValid: true, template }, + }; + + expect( getTemplate( state ) ).toBe( template ); + } ); + } ); + + describe( 'getTemplateLock', () => { + it( 'should return the template object', () => { + const state = { + template: { isValid: true, lock: 'all' }, + }; + + expect( getTemplateLock( state ) ).toBe( 'all' ); + } ); + } ); } );