diff --git a/blocks/api/categories.js b/blocks/api/categories.js index 22d2369255c2a6..1a233f7d68acb2 100644 --- a/blocks/api/categories.js +++ b/blocks/api/categories.js @@ -17,6 +17,7 @@ const categories = [ { slug: 'layout', title: __( 'Layout Blocks' ) }, { slug: 'widgets', title: __( 'Widgets' ) }, { slug: 'embed', title: __( 'Embed' ) }, + { slug: 'reusable-blocks', title: __( 'My Reusable Blocks' ) }, ]; /** diff --git a/blocks/api/factory.js b/blocks/api/factory.js index e775765cab4fe1..cc85b03666d155 100644 --- a/blocks/api/factory.js +++ b/blocks/api/factory.js @@ -11,6 +11,11 @@ import { find, } from 'lodash'; +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + /** * Internal dependencies */ @@ -116,3 +121,19 @@ export function switchToBlockType( block, name ) { uid: index === firstSwitchedBlock ? block.uid : result.uid, } ) ); } + +/** + * Creates a new reusable block. + * + * @param {String} type The type of the block referenced by the reusable block + * @param {Object} attributes The attributes of the block referenced by the reusable block + * @return {Object} A reusable block object + */ +export function createReusableBlock( type, attributes ) { + return { + id: uuid(), + name: __( 'Untitled block' ), + type, + attributes, + }; +} diff --git a/blocks/api/index.js b/blocks/api/index.js index e1a7a5d8d9ab61..30a3dadb11f0a2 100644 --- a/blocks/api/index.js +++ b/blocks/api/index.js @@ -4,7 +4,7 @@ import * as source from './source'; export { source }; -export { createBlock, switchToBlockType } from './factory'; +export { createBlock, switchToBlockType, createReusableBlock } from './factory'; export { default as parse, getSourcedAttributes } from './parser'; export { default as rawHandler } from './raw-handling'; export { default as serialize, getBlockDefaultClassname, getBlockContent } from './serializer'; diff --git a/blocks/api/test/factory.js b/blocks/api/test/factory.js index 0be1b0707c1f05..d5a71698c29b10 100644 --- a/blocks/api/test/factory.js +++ b/blocks/api/test/factory.js @@ -6,7 +6,7 @@ import { noop } from 'lodash'; /** * Internal dependencies */ -import { createBlock, switchToBlockType } from '../factory'; +import { createBlock, switchToBlockType, createReusableBlock } from '../factory'; import { getBlockTypes, unregisterBlockType, setUnknownTypeHandlerName, registerBlockType } from '../registration'; describe( 'block factory', () => { @@ -460,4 +460,18 @@ describe( 'block factory', () => { } ); } ); } ); + + describe( 'createReusableBlock', () => { + it( 'should create a reusable block', () => { + const type = 'core/test-block'; + const attributes = { name: 'Big Bird' }; + + expect( createReusableBlock( type, attributes ) ).toMatchObject( { + id: expect.stringMatching( /\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/ ), + name: 'Untitled block', + type, + attributes, + } ); + } ); + } ); } ); diff --git a/blocks/library/index.js b/blocks/library/index.js index 8ae8ce98543f25..70cfae69fbf07d 100644 --- a/blocks/library/index.js +++ b/blocks/library/index.js @@ -22,3 +22,4 @@ import './text-columns'; import './verse'; import './video'; import './audio'; +import './reusable-block'; diff --git a/blocks/library/reusable-block/index.js b/blocks/library/reusable-block/index.js new file mode 100644 index 00000000000000..800a5cc4cd3b27 --- /dev/null +++ b/blocks/library/reusable-block/index.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { registerBlockType } from '../../api'; + +registerBlockType( 'core/reusable-block', { + title: __( 'Reusable Block' ), + category: 'reusable-blocks', + isPrivate: true, + + attributes: { + ref: { + type: 'string', + }, + }, + + edit: () =>
{ __( 'Reusable Blocks are coming soon!' ) }
, + save: () => null, +} ); diff --git a/blocks/test/fixtures/core__reusable-block.html b/blocks/test/fixtures/core__reusable-block.html new file mode 100644 index 00000000000000..1d4d5f0c09b92a --- /dev/null +++ b/blocks/test/fixtures/core__reusable-block.html @@ -0,0 +1 @@ + diff --git a/blocks/test/fixtures/core__reusable-block.json b/blocks/test/fixtures/core__reusable-block.json new file mode 100644 index 00000000000000..366a117a9e5f12 --- /dev/null +++ b/blocks/test/fixtures/core__reusable-block.json @@ -0,0 +1,11 @@ +[ + { + "uid": "_uid_0", + "name": "core/reusable-block", + "isValid": true, + "attributes": { + "ref": "358b59ee-bab3-4d6f-8445-e8c6971a5605" + }, + "originalContent": "" + } +] diff --git a/blocks/test/fixtures/core__reusable-block.parsed.json b/blocks/test/fixtures/core__reusable-block.parsed.json new file mode 100644 index 00000000000000..8e83afe9b53cd1 --- /dev/null +++ b/blocks/test/fixtures/core__reusable-block.parsed.json @@ -0,0 +1,14 @@ +[ + { + "blockName": "core/reusable-block", + "attrs": { + "ref": "358b59ee-bab3-4d6f-8445-e8c6971a5605" + }, + "rawContent": "" + }, + { + "attrs": {}, + "rawContent": "\n" + } +] + diff --git a/blocks/test/fixtures/core__reusable-block.serialized.html b/blocks/test/fixtures/core__reusable-block.serialized.html new file mode 100644 index 00000000000000..2b3a42824ef3ed --- /dev/null +++ b/blocks/test/fixtures/core__reusable-block.serialized.html @@ -0,0 +1 @@ + diff --git a/editor/actions.js b/editor/actions.js index 7e706dcae11c84..7c061149132a61 100644 --- a/editor/actions.js +++ b/editor/actions.js @@ -491,3 +491,74 @@ export const createSuccessNotice = partial( createNotice, 'success' ); export const createInfoNotice = partial( createNotice, 'info' ); export const createErrorNotice = partial( createNotice, 'error' ); export const createWarningNotice = partial( createNotice, 'warning' ); + +/** + * Returns an action object used to fetch a single reusable block or all + * reusable blocks from the REST API into the store. + * + * @param {?string} id If given, only a single reusable block with this ID will be fetched + * @return {Object} Action object + */ +export function fetchReusableBlocks( id ) { + return { + type: 'FETCH_REUSABLE_BLOCKS', + id, + }; +} + +/** + * Returns an action object used to insert or update a reusable block into the store. + * + * @param {Object} id The ID of the reusable block to update + * @param {Object} reusableBlock The new reusable block object. Any omitted keys are not changed + * @return {Object} Action object + */ +export function updateReusableBlock( id, reusableBlock ) { + return { + type: 'UPDATE_REUSABLE_BLOCK', + id, + reusableBlock, + }; +} + +/** + * Returns an action object used to save a reusable block that's in the store + * to the REST API. + * + * @param {Object} id The ID of the reusable block to save + * @return {Object} Action object + */ +export function saveReusableBlock( id ) { + return { + type: 'SAVE_REUSABLE_BLOCK', + id, + }; +} + +/** + * Returns an action object used to convert a reusable block into a static + * block. + * + * @param {Object} uid The ID of the block to attach + * @return {Object} Action object + */ +export function convertBlockToStatic( uid ) { + return { + type: 'CONVERT_BLOCK_TO_STATIC', + uid, + }; +} + +/** + * Returns an action object used to convert a static block into a reusable + * block. + * + * @param {Object} uid The ID of the block to detach + * @return {Object} Action object + */ +export function convertBlockToReusable( uid ) { + return { + type: 'CONVERT_BLOCK_TO_REUSABLE', + uid, + }; +} diff --git a/editor/inserter/menu.js b/editor/inserter/menu.js index e18c3a2f634294..650a1aac07f069 100644 --- a/editor/inserter/menu.js +++ b/editor/inserter/menu.js @@ -59,7 +59,7 @@ export class InserterMenu extends Component { } componentDidUpdate( prevProps, prevState ) { - const searchResults = this.searchBlocks( getBlockTypes() ); + const searchResults = this.searchBlocks( this.getBlockTypes() ); // Announce the blocks search results to screen readers. if ( !! searchResults.length ) { this.props.debouncedSpeak( sprintf( _n( @@ -100,6 +100,11 @@ export class InserterMenu extends Component { }; } + getBlockTypes() { + // Block types that are marked as private should not appear in the inserter + return getBlockTypes().filter( ( block ) => ! block.isPrivate ); + } + searchBlocks( blockTypes ) { return searchBlocks( blockTypes, this.state.filterValue ); } @@ -107,15 +112,15 @@ export class InserterMenu extends Component { getBlocksForCurrentTab() { // if we're searching, use everything, otherwise just get the blocks visible in this tab if ( this.state.filterValue ) { - return getBlockTypes(); + return this.getBlockTypes(); } switch ( this.state.tab ) { case 'recent': return this.props.recentlyUsedBlocks; case 'blocks': - return filter( getBlockTypes(), ( block ) => block.category !== 'embed' ); + return filter( this.getBlockTypes(), ( block ) => block.category !== 'embed' ); case 'embeds': - return filter( getBlockTypes(), ( block ) => block.category === 'embed' ); + return filter( this.getBlockTypes(), ( block ) => block.category === 'embed' ); } } diff --git a/editor/reducer.js b/editor/reducer.js index fa6e6c9d858ef0..526fdd85bcdecd 100644 --- a/editor/reducer.js +++ b/editor/reducer.js @@ -657,6 +657,56 @@ export function metaBoxes( state = defaultMetaBoxState, action ) { } } +export const reusableBlocks = combineReducers( { + data( state = {}, action ) { + switch ( action.type ) { + case 'FETCH_REUSABLE_BLOCKS_SUCCESS': { + return reduce( action.reusableBlocks, ( newState, reusableBlock ) => ( { + ...newState, + [ reusableBlock.id ]: reusableBlock, + } ), state ); + } + + case 'UPDATE_REUSABLE_BLOCK': { + const { id, reusableBlock } = action; + const existingReusableBlock = state[ id ]; + + return { + ...state, + [ id ]: { + ...existingReusableBlock, + ...reusableBlock, + attributes: { + ...( existingReusableBlock && existingReusableBlock.attributes ), + ...reusableBlock.attributes, + }, + }, + }; + } + } + + return state; + }, + + isSaving( state = {}, action ) { + switch ( action.type ) { + case 'SAVE_REUSABLE_BLOCK': + return { + ...state, + [ action.id ]: true, + }; + + case 'SAVE_REUSABLE_BLOCK_SUCCESS': + case 'SAVE_REUSABLE_BLOCK_FAILURE': { + const { id } = action; + return omit( state, id ); + } + } + + return state; + }, +} ); + export default optimist( combineReducers( { editor, currentPost, @@ -670,4 +720,5 @@ export default optimist( combineReducers( { saving, notices, metaBoxes, + reusableBlocks, } ) ); diff --git a/editor/selectors.js b/editor/selectors.js index 02288e7e68dc29..1ad2a81576ed10 100644 --- a/editor/selectors.js +++ b/editor/selectors.js @@ -1020,3 +1020,35 @@ export const getMostFrequentlyUsedBlocks = createSelector( export function isFeatureActive( state, feature ) { return !! state.preferences.features[ feature ]; } + +/** + * Returns the reusable block with the given ID. + * + * @param {Object} state Global application state + * @param {String} ref The reusable block's ID + * @return {Object} The reusable block, or null if none exists + */ +export function getReusableBlock( state, ref ) { + return state.reusableBlocks.data[ ref ] || null; +} + +/** + * Returns whether or not the reusable block with the given ID is being saved. + * + * @param {*} state Global application state + * @param {*} ref The reusable block's ID + * @return {Boolean} Whether or not the reusable block is being saved + */ +export function isSavingReusableBlock( state, ref ) { + return state.reusableBlocks.isSaving[ ref ] || false; +} + +/** + * Returns an array of all reusable blocks. + * + * @param {Object} state Global application state + * @return {Array} An array of all reusable blocks. + */ +export function getReusableBlocks( state ) { + return Object.values( state.reusableBlocks.data ); +} diff --git a/editor/test/actions.js b/editor/test/actions.js index 8c2d8aec52f71f..cc8b8d05f917f5 100644 --- a/editor/test/actions.js +++ b/editor/test/actions.js @@ -10,6 +10,11 @@ import { handleMetaBoxReload, metaBoxStateChanged, initializeMetaBoxState, + fetchReusableBlocks, + updateReusableBlock, + saveReusableBlock, + convertBlockToStatic, + convertBlockToReusable, } from '../actions'; describe( 'actions', () => { @@ -99,4 +104,63 @@ describe( 'actions', () => { } ); } ); } ); + + describe( 'fetchReusableBlocks', () => { + it( 'should return the FETCH_REUSABLE_BLOCKS action', () => { + expect( fetchReusableBlocks() ).toEqual( { + type: 'FETCH_REUSABLE_BLOCKS', + } ); + } ); + + it( 'should take an optional id argument', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + expect( fetchReusableBlocks( id ) ).toEqual( { + type: 'FETCH_REUSABLE_BLOCKS', + id, + } ); + } ); + } ); + + describe( 'updateReusableBlock', () => { + it( 'should return the UPDATE_REUSABLE_BLOCK action', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const reusableBlock = { + id, + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + }, + }; + expect( updateReusableBlock( id, reusableBlock ) ).toEqual( { + type: 'UPDATE_REUSABLE_BLOCK', + id, + reusableBlock, + } ); + } ); + } ); + + describe( 'saveReusableBlock', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + expect( saveReusableBlock( id ) ).toEqual( { + type: 'SAVE_REUSABLE_BLOCK', + id, + } ); + } ); + + describe( 'convertBlockToStatic', () => { + const uid = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + expect( convertBlockToStatic( uid ) ).toEqual( { + type: 'CONVERT_BLOCK_TO_STATIC', + uid, + } ); + } ); + + describe( 'convertBlockToReusable', () => { + const uid = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + expect( convertBlockToReusable( uid ) ).toEqual( { + type: 'CONVERT_BLOCK_TO_REUSABLE', + uid, + } ); + } ); } ); diff --git a/editor/test/reducer.js b/editor/test/reducer.js index c82de4fe25ce28..9f9360f8e3b422 100644 --- a/editor/test/reducer.js +++ b/editor/test/reducer.js @@ -25,6 +25,7 @@ import { blocksMode, blockInsertionPoint, metaBoxes, + reusableBlocks, } from '../reducer'; describe( 'state', () => { @@ -1233,4 +1234,165 @@ describe( 'state', () => { expect( actual ).toEqual( expected ); } ); } ); + + describe( 'reusableBlocks()', () => { + it( 'should start out empty', () => { + const state = reusableBlocks( undefined, {} ); + expect( state ).toEqual( { + data: {}, + isSaving: {}, + } ); + } ); + + it( 'should add fetched reusable blocks', () => { + const reusableBlock = { + id: '358b59ee-bab3-4d6f-8445-e8c6971a5605', + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + }, + }; + + const state = reusableBlocks( {}, { + type: 'FETCH_REUSABLE_BLOCKS_SUCCESS', + reusableBlocks: [ reusableBlock ], + } ); + + expect( state ).toEqual( { + data: { + [ reusableBlock.id ]: reusableBlock, + }, + isSaving: {}, + } ); + } ); + + it( 'should add a reusable block', () => { + const reusableBlock = { + id: '358b59ee-bab3-4d6f-8445-e8c6971a5605', + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + }, + }; + + const state = reusableBlocks( {}, { + type: 'UPDATE_REUSABLE_BLOCK', + id: reusableBlock.id, + reusableBlock, + } ); + + expect( state ).toEqual( { + data: { + [ reusableBlock.id ]: reusableBlock, + }, + isSaving: {}, + } ); + } ); + + it( 'should update a reusable block', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const initialState = { + data: { + [ id ]: { + id, + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + dropCap: true, + }, + }, + }, + isSaving: {}, + }; + + const state = reusableBlocks( initialState, { + type: 'UPDATE_REUSABLE_BLOCK', + id, + reusableBlock: { + name: 'My better block', + attributes: { + content: 'Yo!', + }, + }, + } ); + + expect( state ).toEqual( { + data: { + [ id ]: { + id, + name: 'My better block', + type: 'core/paragraph', + attributes: { + content: 'Yo!', + dropCap: true, + }, + }, + }, + isSaving: {}, + } ); + } ); + + it( 'should indicate that a reusable block is saving', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const initialState = { + data: {}, + isSaving: {}, + }; + + const state = reusableBlocks( initialState, { + type: 'SAVE_REUSABLE_BLOCK', + id, + } ); + + expect( state ).toEqual( { + data: {}, + isSaving: { + [ id ]: true, + }, + } ); + } ); + + it( 'should stop indicating that a reusable block is saving when the save succeeded', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const initialState = { + data: {}, + isSaving: { + [ id ]: true, + }, + }; + + const state = reusableBlocks( initialState, { + type: 'SAVE_REUSABLE_BLOCK_SUCCESS', + id, + } ); + + expect( state ).toEqual( { + data: {}, + isSaving: {}, + } ); + } ); + + it( 'should stop indicating that a reusable block is saving when there is an error', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const initialState = { + data: {}, + isSaving: { + [ id ]: true, + }, + }; + + const state = reusableBlocks( initialState, { + type: 'SAVE_REUSABLE_BLOCK_FAILURE', + id, + } ); + + expect( state ).toEqual( { + data: {}, + isSaving: {}, + } ); + } ); + } ); } ); diff --git a/editor/test/selectors.js b/editor/test/selectors.js index abd0839d0b706b..5a40bb0f3ffc3f 100644 --- a/editor/test/selectors.js +++ b/editor/test/selectors.js @@ -73,6 +73,9 @@ import { getDirtyMetaBoxes, getMetaBox, isMetaBoxStateDirty, + getReusableBlock, + isSavingReusableBlock, + getReusableBlocks, } from '../selectors'; describe( 'selectors', () => { @@ -2023,4 +2026,109 @@ describe( 'selectors', () => { .toEqual( [ 'core/paragraph', 'core/image' ] ); } ); } ); + + describe( 'getReusableBlock', () => { + it( 'should return a reusable block', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const expectedReusableBlock = { + id, + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + }, + }; + const state = { + reusableBlocks: { + data: { + [ id ]: expectedReusableBlock, + }, + }, + }; + + const actualReusableBlock = getReusableBlock( state, id ); + expect( actualReusableBlock ).toEqual( expectedReusableBlock ); + } ); + + it( 'should return null when no reusable block exists', () => { + const state = { + reusableBlocks: { + data: {}, + }, + }; + + const reusableBlock = getReusableBlock( state, '358b59ee-bab3-4d6f-8445-e8c6971a5605' ); + expect( reusableBlock ).toBeNull(); + } ); + } ); + + describe( 'isSavingReusableBlock', () => { + it( 'should return false when the block is not being saved', () => { + const state = { + reusableBlocks: { + isSaving: {}, + }, + }; + + const isSaving = isSavingReusableBlock( state, '358b59ee-bab3-4d6f-8445-e8c6971a5605' ); + expect( isSaving ).toBe( false ); + } ); + + it( 'should return true when the block is being saved', () => { + const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; + const state = { + reusableBlocks: { + isSaving: { + [ id ]: true, + }, + }, + }; + + const isSaving = isSavingReusableBlock( state, id ); + expect( isSaving ).toBe( true ); + } ); + } ); + + describe( 'getReusableBlocks', () => { + it( 'should return an array of reusable blocks', () => { + const reusableBlock1 = { + id: '358b59ee-bab3-4d6f-8445-e8c6971a5605', + name: 'My cool block', + type: 'core/paragraph', + attributes: { + content: 'Hello!', + }, + }; + const reusableBlock2 = { + id: '687e1a87-cca1-41f2-a782-197ddaea9abf', + name: 'My neat block', + type: 'core/paragraph', + attributes: { + content: 'Goodbye!', + }, + }; + const state = { + reusableBlocks: { + data: { + [ reusableBlock1.id ]: reusableBlock1, + [ reusableBlock2.id ]: reusableBlock2, + }, + }, + }; + + const reusableBlocks = getReusableBlocks( state ); + expect( reusableBlocks ).toEqual( [ reusableBlock1, reusableBlock2 ] ); + } ); + + it( 'should return an empty array when no reusable blocks exist', () => { + const state = { + reusableBlocks: { + data: {}, + }, + }; + + const reusableBlocks = getReusableBlocks( state ); + expect( reusableBlocks ).toEqual( [] ); + } ); + } ); } );