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( [] );
+ } );
+ } );
} );