+ );
+ }
+
+ const resultsFoundMessage = sprintf(
+ _n( 'No blocks found in your library. We did find %d block available for download.', 'No blocks found in your library. We did find %d blocks available for download.', downloadableItems.length ),
+ downloadableItems.length
+ );
+
+ debouncedSpeak( resultsFoundMessage );
+ return (
+
+
+ { __( 'No blocks found in your library. These blocks can be downloaded and installed:' ) }
+
+
+
+ );
+}
+
+export default compose( [
+ withSpokenMessages,
+ withSelect( ( select, { filterValue } ) => {
+ const {
+ getDownloadableBlocks,
+ hasInstallBlocksPermission,
+ isRequestingDownloadableBlocks,
+ } = select( 'core/block-directory' );
+
+ const hasPermission = hasInstallBlocksPermission();
+ const downloadableItems = hasPermission ? getDownloadableBlocks( filterValue ) : [];
+ const isLoading = isRequestingDownloadableBlocks();
+
+ return {
+ downloadableItems,
+ hasPermission,
+ isLoading,
+ };
+ } ),
+] )( DownloadableBlocksPanel );
diff --git a/packages/block-directory/src/components/downloadable-blocks-panel/style.scss b/packages/block-directory/src/components/downloadable-blocks-panel/style.scss
new file mode 100644
index 00000000000000..62a6a11c0fcabe
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-blocks-panel/style.scss
@@ -0,0 +1,19 @@
+
+.block-directory-downloadable-blocks-panel__description {
+ font-style: italic;
+ padding: 0;
+ margin-top: 0;
+ text-align: left;
+ color: $dark-gray-400;
+}
+
+.block-directory-downloadable-blocks-panel__description.has-no-results {
+ font-style: normal;
+ padding: 0;
+ margin-top: 100px;
+ text-align: center;
+ color: $dark-gray-400;
+ .components-spinner {
+ float: inherit;
+ }
+}
diff --git a/packages/block-directory/src/index.js b/packages/block-directory/src/index.js
new file mode 100644
index 00000000000000..5c088e8d5928d3
--- /dev/null
+++ b/packages/block-directory/src/index.js
@@ -0,0 +1,6 @@
+/**
+ * Internal dependencies
+ */
+import './store';
+
+export { default as DownloadableBlocksPanel } from './components/downloadable-blocks-panel';
diff --git a/packages/block-directory/src/store/actions.js b/packages/block-directory/src/store/actions.js
new file mode 100644
index 00000000000000..46bce90d2f7cb8
--- /dev/null
+++ b/packages/block-directory/src/store/actions.js
@@ -0,0 +1,148 @@
+/**
+ * WordPress dependencies
+ */
+import { getBlockTypes } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import { apiFetch, loadAssets } from './controls';
+
+/**
+ * Returns an action object used in signalling that the downloadable blocks have been requested and is loading.
+ *
+ * @return {Object} Action object.
+ */
+export function fetchDownloadableBlocks() {
+ return { type: 'FETCH_DOWNLOADABLE_BLOCKS' };
+}
+
+/**
+ * Returns an action object used in signalling that the downloadable blocks have been updated.
+ *
+ * @param {Array} downloadableBlocks Downloadable blocks.
+ * @param {string} filterValue Search string.
+ *
+ * @return {Object} Action object.
+ */
+export function receiveDownloadableBlocks( downloadableBlocks, filterValue ) {
+ return { type: 'RECEIVE_DOWNLOADABLE_BLOCKS', downloadableBlocks, filterValue };
+}
+
+/**
+ * Returns an action object used in signalling that the user does not have permission to install blocks.
+ *
+ @param {boolean} hasPermission User has permission to install blocks.
+ *
+ * @return {Object} Action object.
+ */
+export function setInstallBlocksPermission( hasPermission ) {
+ return { type: 'SET_INSTALL_BLOCKS_PERMISSION', hasPermission };
+}
+
+/**
+ * Action triggered to download block assets.
+ *
+ * @param {Object} item The selected block item
+ * @param {Function} onSuccess The callback function when the action has succeeded.
+ * @param {Function} onError The callback function when the action has failed.
+ */
+export function* downloadBlock( item, onSuccess, onError ) {
+ try {
+ if ( ! item.assets.length ) {
+ throw new Error( 'Block has no assets' );
+ }
+
+ yield loadAssets( item.assets );
+ const registeredBlocks = getBlockTypes();
+ if ( registeredBlocks.length ) {
+ onSuccess( item );
+ } else {
+ throw new Error( 'Unable to get block types' );
+ }
+ } catch ( error ) {
+ yield onError( error );
+ }
+}
+
+/**
+ * Action triggered to install a block plugin.
+ *
+ * @param {string} item The block item returned by search.
+ * @param {Function} onSuccess The callback function when the action has succeeded.
+ * @param {Function} onError The callback function when the action has failed.
+ *
+ */
+export function* installBlock( { id, name }, onSuccess, onError ) {
+ try {
+ const response = yield apiFetch( {
+ path: '__experimental/block-directory/install',
+ data: {
+ slug: id,
+ },
+ method: 'POST',
+ } );
+ if ( response.success === false ) {
+ throw new Error( response.errorMessage );
+ }
+ yield addInstalledBlockType( { id, name } );
+ onSuccess();
+ } catch ( error ) {
+ onError( error );
+ }
+}
+
+/**
+ * Action triggered to uninstall a block plugin.
+ *
+ * @param {string} item The block item returned by search.
+ * @param {Function} onSuccess The callback function when the action has succeeded.
+ * @param {Function} onError The callback function when the action has failed.
+ *
+ */
+export function* uninstallBlock( { id, name }, onSuccess, onError ) {
+ try {
+ const response = yield apiFetch( {
+ path: '__experimental/block-directory/uninstall',
+ data: {
+ slug: id,
+ },
+ method: 'DELETE',
+ } );
+ if ( response.success === false ) {
+ throw new Error( response.errorMessage );
+ }
+ yield removeInstalledBlockType( { id, name } );
+ onSuccess();
+ } catch ( error ) {
+ onError( error );
+ }
+}
+
+/**
+ * Returns an action object used to add a newly installed block type.
+ *
+ * @param {string} item The block item with the block id and name.
+ *
+ * @return {Object} Action object.
+ */
+export function addInstalledBlockType( item ) {
+ return {
+ type: 'ADD_INSTALLED_BLOCK_TYPE',
+ item,
+ };
+}
+
+/**
+ * Returns an action object used to remove a newly installed block type.
+ *
+ * @param {string} item The block item with the block id and name.
+ *
+ * @return {Object} Action object.
+ */
+export function removeInstalledBlockType( item ) {
+ return {
+ type: 'REMOVE_INSTALLED_BLOCK_TYPE',
+ item,
+ };
+}
diff --git a/packages/block-directory/src/store/controls.js b/packages/block-directory/src/store/controls.js
new file mode 100644
index 00000000000000..f7940e9d31c209
--- /dev/null
+++ b/packages/block-directory/src/store/controls.js
@@ -0,0 +1,151 @@
+/**
+ * External dependencies
+ */
+import { forEach } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { createRegistryControl } from '@wordpress/data';
+import wpApiFetch from '@wordpress/api-fetch';
+
+/**
+ * Calls a selector using the current state.
+ *
+ * @param {string} storeName Store name.
+ * @param {string} selectorName Selector name.
+ * @param {Array} args Selector arguments.
+ *
+ * @return {Object} Control descriptor.
+ */
+export function select( storeName, selectorName, ...args ) {
+ return {
+ type: 'SELECT',
+ storeName,
+ selectorName,
+ args,
+ };
+}
+
+/**
+ * Calls a dispatcher using the current state.
+ *
+ * @param {string} storeName Store name.
+ * @param {string} dispatcherName Dispatcher name.
+ * @param {Array} args Selector arguments.
+ *
+ * @return {Object} Control descriptor.
+ */
+export function dispatch( storeName, dispatcherName, ...args ) {
+ return {
+ type: 'DISPATCH',
+ storeName,
+ dispatcherName,
+ args,
+ };
+}
+
+/**
+ * Trigger an API Fetch request.
+ *
+ * @param {Object} request API Fetch Request Object.
+ *
+ * @return {Object} Control descriptor.
+ */
+export function apiFetch( request ) {
+ return {
+ type: 'API_FETCH',
+ request,
+ };
+}
+
+/**
+ * Loads JavaScript
+ *
+ * @param {Array} asset The url for the JavaScript.
+ * @param {Function} onLoad Callback function on success.
+ * @param {Function} onError Callback funciton on failure.
+ */
+const loadScript = ( asset, onLoad, onError ) => {
+ if ( ! asset ) {
+ return;
+ }
+ const existing = document.querySelector( `script[src="${ asset.src }"]` );
+ if ( existing ) {
+ existing.parentNode.removeChild( existing );
+ }
+ const script = document.createElement( 'script' );
+ script.src = typeof asset === 'string' ? asset : asset.src;
+ script.onload = onLoad;
+ script.onerror = onError;
+ document.body.appendChild( script );
+};
+
+/**
+ * Loads CSS file.
+ *
+ * @param {*} asset the url for the CSS file.
+ */
+const loadStyle = ( asset ) => {
+ if ( ! asset ) {
+ return;
+ }
+ const link = document.createElement( 'link' );
+ link.rel = 'stylesheet';
+ link.href = typeof asset === 'string' ? asset : asset.src;
+ document.body.appendChild( link );
+};
+
+/**
+ * Load the asset files for a block
+ *
+ * @param {Array} assets A collection of URL for the assets.
+ *
+ * @return {Object} Control descriptor.
+ */
+export function* loadAssets( assets ) {
+ return {
+ type: 'LOAD_ASSETS',
+ assets,
+ };
+}
+
+const controls = {
+ SELECT: createRegistryControl( ( registry ) => ( { storeName, selectorName, args } ) => {
+ return registry.select( storeName )[ selectorName ]( ...args );
+ } ),
+ DISPATCH: createRegistryControl( ( registry ) => ( { storeName, dispatcherName, args } ) => {
+ return registry.dispatch( storeName )[ dispatcherName ]( ...args );
+ } ),
+ API_FETCH( { request } ) {
+ return wpApiFetch( { ... request } );
+ },
+ LOAD_ASSETS( { assets } ) {
+ return new Promise( ( resolve, reject ) => {
+ if ( Array.isArray( assets ) ) {
+ let scriptsCount = 0;
+
+ forEach( assets, ( asset ) => {
+ if ( asset.match( /\.js$/ ) !== null ) {
+ scriptsCount++;
+ loadScript( asset, () => {
+ scriptsCount--;
+ if ( scriptsCount === 0 ) {
+ return resolve( scriptsCount );
+ }
+ }, reject );
+ } else {
+ loadStyle( asset );
+ }
+ } );
+ } else {
+ loadScript( assets.editor_script, () => {
+ return resolve( 0 );
+ }, reject );
+ loadStyle( assets.style );
+ }
+ } );
+ },
+};
+
+export default controls;
diff --git a/packages/block-directory/src/store/index.js b/packages/block-directory/src/store/index.js
new file mode 100644
index 00000000000000..1488e7be94d828
--- /dev/null
+++ b/packages/block-directory/src/store/index.js
@@ -0,0 +1,37 @@
+/**
+ * WordPress dependencies
+ */
+import { registerStore } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import reducer from './reducer';
+import * as selectors from './selectors';
+import * as actions from './actions';
+import resolvers from './resolvers';
+import controls from './controls';
+
+/**
+ * Module Constants
+ */
+const MODULE_KEY = 'core/block-directory';
+
+/**
+ * Block editor data store configuration.
+ *
+ * @see https://github.com/WordPress/gutenberg/blob/master/packages/data/README.md#registerStore
+ *
+ * @type {Object}
+ */
+export const storeConfig = {
+ reducer,
+ selectors,
+ actions,
+ controls,
+ resolvers,
+};
+
+const store = registerStore( MODULE_KEY, storeConfig );
+
+export default store;
diff --git a/packages/block-directory/src/store/reducer.js b/packages/block-directory/src/store/reducer.js
new file mode 100644
index 00000000000000..8dc4f3438c318f
--- /dev/null
+++ b/packages/block-directory/src/store/reducer.js
@@ -0,0 +1,59 @@
+/**
+ * WordPress dependencies
+ */
+import { combineReducers } from '@wordpress/data';
+
+/**
+ * Reducer returning an array of downloadable blocks.
+ *
+ * @param {Object} state Current state.
+ * @param {Object} action Dispatched action.
+ *
+ * @return {Object} Updated state.
+ */
+export const downloadableBlocks = ( state = {
+ results: {},
+ hasPermission: true,
+ filterValue: undefined,
+ isRequestingDownloadableBlocks: true,
+ installedBlockTypes: [],
+}, action ) => {
+ switch ( action.type ) {
+ case 'FETCH_DOWNLOADABLE_BLOCKS' :
+ return {
+ ...state,
+ isRequestingDownloadableBlocks: true,
+ };
+ case 'RECEIVE_DOWNLOADABLE_BLOCKS' :
+ return {
+ ...state,
+ results: Object.assign( {}, state.results, {
+ [ action.filterValue ]: action.downloadableBlocks,
+ } ),
+ hasPermission: true,
+ isRequestingDownloadableBlocks: false,
+ };
+ case 'SET_INSTALL_BLOCKS_PERMISSION' :
+ return {
+ ...state,
+ items: action.hasPermission ? state.items : [],
+ hasPermission: action.hasPermission,
+ };
+ case 'ADD_INSTALLED_BLOCK_TYPE' :
+ return {
+ ...state,
+ installedBlockTypes: [ ...state.installedBlockTypes, action.item ],
+ };
+
+ case 'REMOVE_INSTALLED_BLOCK_TYPE' :
+ return {
+ ...state,
+ installedBlockTypes: state.installedBlockTypes.filter( ( blockType ) => blockType.name !== action.item.name ),
+ };
+ }
+ return state;
+};
+
+export default combineReducers( {
+ downloadableBlocks,
+} );
diff --git a/packages/block-directory/src/store/resolvers.js b/packages/block-directory/src/store/resolvers.js
new file mode 100644
index 00000000000000..885257ea72ee8b
--- /dev/null
+++ b/packages/block-directory/src/store/resolvers.js
@@ -0,0 +1,46 @@
+/**
+ * External dependencies
+ */
+import { camelCase, mapKeys } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import { apiFetch } from './controls';
+import { fetchDownloadableBlocks, receiveDownloadableBlocks, setInstallBlocksPermission } from './actions';
+
+export default {
+ * getDownloadableBlocks( filterValue ) {
+ if ( ! filterValue ) {
+ return;
+ }
+
+ try {
+ yield fetchDownloadableBlocks( filterValue );
+ const results = yield apiFetch( {
+ path: `__experimental/block-directory/search?term=${ filterValue }`,
+ } );
+ const blocks = results.map( ( result ) => mapKeys( result, ( value, key ) => {
+ return camelCase( key );
+ } ) );
+
+ yield receiveDownloadableBlocks( blocks, filterValue );
+ } catch ( error ) {
+ if ( error.code === 'rest_user_cannot_view' ) {
+ yield setInstallBlocksPermission( false );
+ }
+ }
+ },
+ * hasInstallBlocksPermission() {
+ try {
+ yield apiFetch( {
+ path: `__experimental/block-directory/search?term=`,
+ } );
+ yield setInstallBlocksPermission( true );
+ } catch ( error ) {
+ if ( error.code === 'rest_user_cannot_view' ) {
+ yield setInstallBlocksPermission( false );
+ }
+ }
+ },
+};
diff --git a/packages/block-directory/src/store/selectors.js b/packages/block-directory/src/store/selectors.js
new file mode 100644
index 00000000000000..daa8384daff758
--- /dev/null
+++ b/packages/block-directory/src/store/selectors.js
@@ -0,0 +1,52 @@
+/**
+ * External dependencies
+ */
+import { get } from 'lodash';
+
+/**
+ * Returns true if application is requesting for downloable blocks.
+ *
+ * @param {Object} state Global application state.
+ *
+ * @return {Array} Downloadable blocks
+ */
+export function isRequestingDownloadableBlocks( state ) {
+ return state.downloadableBlocks.isRequestingDownloadableBlocks;
+}
+
+/**
+ * Returns the available uninstalled blocks
+ *
+ * @param {Object} state Global application state.
+ * @param {string} filterValue Search string.
+ *
+ * @return {Array} Downloadable blocks
+ */
+export function getDownloadableBlocks( state, filterValue ) {
+ if ( ! state.downloadableBlocks.results[ filterValue ] ) {
+ return [];
+ }
+ return state.downloadableBlocks.results[ filterValue ];
+}
+
+/**
+ * Returns true if user has permission to install blocks.
+ *
+ * @param {Object} state Global application state.
+ *
+ * @return {boolean} User has permission to install blocks.
+ */
+export function hasInstallBlocksPermission( state ) {
+ return state.downloadableBlocks.hasPermission;
+}
+
+/**
+ * Returns the block types that have been installed on the server.
+ *
+ * @param {Object} state Global application state.
+ *
+ * @return {Array} Block type items.
+ */
+export function getInstalledBlockTypes( state ) {
+ return get( state, [ 'downloadableBlocks', 'installedBlockTypes' ], [] );
+}
diff --git a/packages/block-directory/src/style.scss b/packages/block-directory/src/style.scss
new file mode 100644
index 00000000000000..14f33e678ede45
--- /dev/null
+++ b/packages/block-directory/src/style.scss
@@ -0,0 +1,7 @@
+@import "./components/downloadable-block-header/style.scss";
+@import "./components/downloadable-block-info/style.scss";
+@import "./components/downloadable-block-author-info/style.scss";
+@import "./components/downloadable-block-list-item/style.scss";
+@import "./components/downloadable-blocks-list/style.scss";
+@import "./components/downloadable-blocks-panel/style.scss";
+@import "./components/block-ratings/style.scss";
diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md
index 798ae994fb27b0..3d3e53c2038411 100644
--- a/packages/block-editor/README.md
+++ b/packages/block-editor/README.md
@@ -394,7 +394,8 @@ The default editor settings
showInserterHelpPanel boolean Whether or not the inserter help panel is shown
**experimentalCanUserUseUnfilteredHTML string Whether the user should be able to use unfiltered HTML or the HTML should be filtered e.g., to remove elements considered insecure like iframes.
**experimentalEnableLegacyWidgetBlock boolean Whether the user has enabled the Legacy Widget Block
- \_\_experimentalEnableMenuBlock boolean Whether the user has enabled the Menu Block
+ **experimentalEnableMenuBlock boolean Whether the user has enabled the Menu Block
+ **experimentalBlockDirectory boolean Whether the user has enabled the Block Directory
# **SkipToSelectedBlock**
diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js
index e2c4a764f46354..65b10d6bfd17c1 100644
--- a/packages/block-editor/src/components/index.js
+++ b/packages/block-editor/src/components/index.js
@@ -42,6 +42,7 @@ export { default as withColorContext } from './color-palette/with-color-context'
export { default as __experimentalBlockSettingsMenuFirstItem } from './block-settings-menu/block-settings-menu-first-item';
export { default as __experimentalBlockSettingsMenuPluginsExtension } from './block-settings-menu/block-settings-menu-plugins-extension';
+export { default as __experimentalInserterMenuExtension } from './inserter-menu-extension';
export { default as BlockEditorKeyboardShortcuts } from './block-editor-keyboard-shortcuts';
export { default as BlockInspector } from './block-inspector';
export { default as BlockList } from './block-list';
diff --git a/packages/block-editor/src/components/inserter-menu-extension/index.js b/packages/block-editor/src/components/inserter-menu-extension/index.js
new file mode 100644
index 00000000000000..bc493945d9f363
--- /dev/null
+++ b/packages/block-editor/src/components/inserter-menu-extension/index.js
@@ -0,0 +1,10 @@
+/**
+ * WordPress dependencies
+ */
+import { createSlotFill } from '@wordpress/components';
+
+const { Fill: __experimentalInserterMenuExtension, Slot } = createSlotFill( '__experimentalInserterMenuExtension' );
+
+__experimentalInserterMenuExtension.Slot = Slot;
+
+export default __experimentalInserterMenuExtension;
diff --git a/packages/block-editor/src/components/inserter/menu.js b/packages/block-editor/src/components/inserter/menu.js
index 0d555633d91a96..7fda4a6f572b3c 100644
--- a/packages/block-editor/src/components/inserter/menu.js
+++ b/packages/block-editor/src/components/inserter/menu.js
@@ -47,6 +47,7 @@ import BlockPreview from '../block-preview';
import BlockTypesList from '../block-types-list';
import BlockCard from '../block-card';
import ChildBlocks from './child-blocks';
+import __experimentalInserterMenuExtension from '../inserter-menu-extension';
const MAX_SUGGESTED_ITEMS = 9;
@@ -197,6 +198,7 @@ export class InserterMenu extends Component {
filter( filterValue = '' ) {
const { debouncedSpeak, items, rootChildBlocks } = this.props;
+
const filteredItems = searchItems( items, filterValue );
const childItems = filter( filteredItems, ( { name } ) => includes( rootChildBlocks, name ) );
@@ -241,7 +243,6 @@ export class InserterMenu extends Component {
_n( '%d result found.', '%d results found.', resultCount ),
resultCount
);
-
debouncedSpeak( resultsFoundMessage );
}
@@ -263,6 +264,7 @@ export class InserterMenu extends Component {
suggestedItems,
} = this.state;
const isPanelOpen = ( panel ) => openPanels.indexOf( panel ) !== -1;
+ const hasItems = isEmpty( suggestedItems ) && isEmpty( reusableItems ) && isEmpty( itemsPerCategory );
const hoveredItemBlockType = hoveredItem ? getBlockType( hoveredItem.name ) : null;
// Disable reason (no-autofocus): The inserter menu is a modal display, not one which
@@ -355,9 +357,27 @@ export class InserterMenu extends Component {
) }
- { isEmpty( suggestedItems ) && isEmpty( reusableItems ) && isEmpty( itemsPerCategory ) && (
-