diff --git a/docs/manifest-devhub.json b/docs/manifest-devhub.json
index 277a832fd419f4..68958986ae8ea2 100644
--- a/docs/manifest-devhub.json
+++ b/docs/manifest-devhub.json
@@ -1085,6 +1085,12 @@
"markdown_source": "../packages/blob/README.md",
"parent": "packages"
},
+ {
+ "title": "@wordpress/block-directory",
+ "slug": "packages-block-directory",
+ "markdown_source": "../packages/block-directory/README.md",
+ "parent": "packages"
+ },
{
"title": "@wordpress/block-editor",
"slug": "packages-block-editor",
diff --git a/lib/class-wp-rest-block-directory-controller.php b/lib/class-wp-rest-block-directory-controller.php
new file mode 100644
index 00000000000000..ffe149c5beea57
--- /dev/null
+++ b/lib/class-wp-rest-block-directory-controller.php
@@ -0,0 +1,311 @@
+namespace = '__experimental';
+ $this->rest_base = 'block-directory';
+ }
+
+ /**
+ * Registers the necessary REST API routes.
+ *
+ * @access public
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/search',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_item_schema' ),
+ )
+ );
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/install',
+ array(
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'install_block' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_item_schema' ),
+ )
+ );
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/uninstall',
+ array(
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'uninstall_block' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to install and activate plugins.
+ *
+ * @since 5.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|bool True if the request has permission, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( ! current_user_can( 'install_plugins' ) || ! current_user_can( 'activate_plugins' ) ) {
+ return new WP_Error(
+ 'rest_user_cannot_view',
+ __( 'Sorry, you are not allowed to install blocks.', 'gutenberg' )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Installs and activates a plugin
+ *
+ * @since 5.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
+ */
+ public function install_block( $request ) {
+
+ include_once( ABSPATH . 'wp-admin/includes/file.php' );
+ include_once( ABSPATH . 'wp-admin/includes/plugin.php' );
+ include_once( ABSPATH . 'wp-admin/includes/class-wp-upgrader.php' );
+ include_once( ABSPATH . 'wp-admin/includes/plugin-install.php' );
+
+ $api = plugins_api(
+ 'plugin_information',
+ array(
+ 'slug' => $request->get_param( 'slug' ),
+ 'fields' => array(
+ 'sections' => false,
+ ),
+ )
+ );
+
+ if ( is_wp_error( $api ) ) {
+ return WP_Error( $api->get_error_code(), $api->get_error_message() );
+ }
+
+ $skin = new WP_Ajax_Upgrader_Skin();
+ $upgrader = new Plugin_Upgrader( $skin );
+
+ $filesystem_method = get_filesystem_method();
+
+ if ( 'direct' !== $filesystem_method ) {
+ return WP_Error( null, 'Only direct FS_METHOD is supported.' );
+ }
+
+ $result = $upgrader->install( $api->download_link );
+
+ if ( is_wp_error( $result ) ) {
+ return WP_Error( $result->get_error_code(), $result->get_error_message() );
+ }
+
+ if ( is_wp_error( $skin->result ) ) {
+ return WP_Error( $skin->$result->get_error_code(), $skin->$result->get_error_message() );
+ }
+
+ if ( $skin->get_errors()->has_errors() ) {
+ return WP_Error( $skin->$result->get_error_code(), $skin->$result->get_error_messages() );
+ }
+
+ if ( is_null( $result ) ) {
+ global $wp_filesystem;
+ // Pass through the error from WP_Filesystem if one was raised.
+ if ( $wp_filesystem instanceof WP_Filesystem_Base && is_wp_error( $wp_filesystem->errors ) && $wp_filesystem->errors->has_errors() ) {
+ return WP_Error( 'unable_to_connect_to_filesystem', esc_html( $wp_filesystem->errors->get_error_message() ) );
+ }
+ return WP_Error( 'unable_to_connect_to_filesystem', __( 'Unable to connect to the filesystem. Please confirm your credentials.', 'gutenberg' ) );
+ }
+
+ $install_status = install_plugin_install_status( $api );
+
+ $activate_result = activate_plugin( $install_status['file'] );
+
+ if ( is_wp_error( $activate_result ) ) {
+ return WP_Error( $activate_result->get_error_code(), $activate_result->get_error_message() );
+ }
+
+ return rest_ensure_response( true );
+ }
+
+ /**
+ * Deactivates and deletes a plugin
+ *
+ * @since 5.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
+ */
+ public function uninstall_block( $request ) {
+
+ include_once( ABSPATH . 'wp-admin/includes/file.php' );
+ include_once( ABSPATH . 'wp-admin/includes/plugin.php' );
+ include_once( ABSPATH . 'wp-admin/includes/class-wp-upgrader.php' );
+ include_once( ABSPATH . 'wp-admin/includes/plugin-install.php' );
+
+ $api = plugins_api(
+ 'plugin_information',
+ array(
+ 'slug' => $request->get_param( 'slug' ),
+ 'fields' => array(
+ 'sections' => false,
+ ),
+ )
+ );
+
+ if ( is_wp_error( $api ) ) {
+ return WP_Error( $api->get_error_code(), $api->get_error_message() );
+ }
+
+ $install_status = install_plugin_install_status( $api );
+
+ $deactivate_result = deactivate_plugins( $install_status['file'] );
+
+ if ( is_wp_error( $deactivate_result ) ) {
+ return WP_Error( $deactivate_result->get_error_code(), $deactivate_result->get_error_message() );
+ }
+
+ $delete_result = delete_plugins( array( $install_status['file'] ) );
+
+ if ( is_wp_error( $delete_result ) ) {
+ return WP_Error( $delete_result->get_error_code(), $delete_result->get_error_message() );
+ }
+
+ return rest_ensure_response( true );
+ }
+
+ /**
+ * Search and retrieve blocks metadata
+ *
+ * @since 5.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+
+ $search_string = $request->get_param( 'term' );
+
+ if ( empty( $search_string ) ) {
+ return rest_ensure_response( array() );
+ }
+
+ include( ABSPATH . WPINC . '/version.php' );
+
+ $url = 'http://api.wordpress.org/plugins/info/1.2/';
+ $url = add_query_arg(
+ array(
+ 'action' => 'query_plugins',
+ 'request[block]' => $search_string,
+ 'request[wp_version]' => '5.3',
+ 'request[per_page]' => '3',
+ ),
+ $url
+ );
+ $http_url = $url;
+ $ssl = wp_http_supports( array( 'ssl' ) );
+ if ( $ssl ) {
+ $url = set_url_scheme( $url, 'https' );
+ }
+ $http_args = array(
+ 'timeout' => 15,
+ 'user-agent' => 'WordPress/' . $wp_version . '; ' . home_url( '/' ),
+ );
+
+ $request = wp_remote_get( $url, $http_args );
+ $response = json_decode( wp_remote_retrieve_body( $request ), true );
+
+ if ( ! function_exists( 'get_plugins' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+
+ $result = array();
+
+ foreach ( $response['plugins'] as $plugin ) {
+ $installed_plugins = get_plugins( '/' . $plugin['slug'] );
+
+ // Only show uninstalled blocks.
+ if ( empty( $installed_plugins ) ) {
+ $result[] = parse_block_metadata( $plugin );
+ }
+ }
+
+ return rest_ensure_response( $result );
+ }
+}
+
+ /**
+ * Parse block metadata for a block
+ *
+ * @since 5.7.0
+ *
+ * @param WP_Object $plugin The plugin metadata.
+ * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
+ */
+function parse_block_metadata( $plugin ) {
+ $block = new stdClass();
+
+ // There might be multiple blocks in a plugin. Only the first block is mapped.
+ $block_data = reset( $plugin['blocks'] );
+ $block->name = $block_data['name'];
+ $block->title = $block_data['title'];
+
+ // Plugin's description, not description in block.json.
+ $block->description = wp_trim_words( wp_strip_all_tags( $plugin['description'] ), 30, '...' );
+
+ $block->id = $plugin['slug'];
+ $block->rating = $plugin['rating'];
+ $block->rating_count = $plugin['num_ratings'];
+ $block->active_installs = $plugin['active_installs'];
+ $block->author_block_rating = $plugin['author_block_rating'];
+ $block->author_block_count = $plugin['author_block_count'];
+
+ // Plugin's author, not author in block.json.
+ $block->author = wp_strip_all_tags( $plugin['author'] );
+
+ // Plugin's icons or icon in block.json.
+ $block->icon = isset( $plugin['icons']['1x'] ) ? $plugin['icons']['1x'] : 'block-default';
+
+ $block->assets = array();
+
+ foreach ( $plugin['block_assets'] as $asset ) {
+ $block->assets[] = 'https://plugins.svn.wordpress.org/' . $plugin['slug'] . $asset;
+ }
+
+ $block->humanized_updated = human_time_diff( strtotime( $plugin['last_updated'] ), current_time( 'timestamp' ) ) . __( ' ago', 'gutenberg' );
+
+ return $block;
+}
diff --git a/lib/client-assets.php b/lib/client-assets.php
index 669f9597c76a16..dcfec61d4bf883 100644
--- a/lib/client-assets.php
+++ b/lib/client-assets.php
@@ -290,7 +290,7 @@ function gutenberg_register_scripts_and_styles() {
gutenberg_override_style(
'wp-editor',
gutenberg_url( 'build/editor/style.css' ),
- array( 'wp-components', 'wp-block-editor', 'wp-nux' ),
+ array( 'wp-components', 'wp-block-editor', 'wp-nux', 'wp-block-directory' ),
filemtime( gutenberg_dir_path() . 'build/editor/style.css' )
);
wp_style_add_data( 'wp-editor', 'rtl', 'replace' );
@@ -373,6 +373,14 @@ function gutenberg_register_scripts_and_styles() {
);
wp_style_add_data( 'wp-edit-widgets', 'rtl', 'replace' );
+ gutenberg_override_style(
+ 'wp-block-directory',
+ gutenberg_url( 'build/block-directory/style.css' ),
+ array( 'wp-components' ),
+ filemtime( gutenberg_dir_path() . 'build/block-directory/style.css' )
+ );
+ wp_style_add_data( 'wp-block-directory', 'rtl', 'replace' );
+
if ( defined( 'GUTENBERG_LIVE_RELOAD' ) && GUTENBERG_LIVE_RELOAD ) {
$live_reload_url = ( GUTENBERG_LIVE_RELOAD === true ) ? 'http://localhost:35729/livereload.js' : GUTENBERG_LIVE_RELOAD;
diff --git a/lib/experiments-page.php b/lib/experiments-page.php
index 0ca203444dc81f..a0db70232f2c20 100644
--- a/lib/experiments-page.php
+++ b/lib/experiments-page.php
@@ -22,7 +22,7 @@ class="wrap"
@@ -64,6 +64,17 @@ function gutenberg_initialize_experiments_settings() {
'id' => 'gutenberg-menu-block',
)
);
+ add_settings_field(
+ 'gutenberg-block-directory',
+ __( 'Block Directory', 'gutenberg' ),
+ 'gutenberg_display_experiment_field',
+ 'gutenberg-experiments',
+ 'gutenberg_experiments_section',
+ array(
+ 'label' => __( 'Enable Block Directory search', 'gutenberg' ),
+ 'id' => 'gutenberg-block-directory',
+ )
+ );
register_setting(
'gutenberg-experiments',
'gutenberg-experiments'
@@ -114,6 +125,8 @@ function gutenberg_experiments_editor_settings( $settings ) {
$experiments_settings = array(
'__experimentalEnableLegacyWidgetBlock' => $experiments_exist ? array_key_exists( 'gutenberg-widget-experiments', get_option( 'gutenberg-experiments' ) ) : false,
'__experimentalEnableMenuBlock' => $experiments_exist ? array_key_exists( 'gutenberg-menu-block', get_option( 'gutenberg-experiments' ) ) : false,
+ '__experimentalBlockDirectory' => $experiments_exist ? array_key_exists( 'gutenberg-block-directory', get_option( 'gutenberg-experiments' ) ) : false,
+
);
return array_merge( $settings, $experiments_settings );
}
diff --git a/lib/load.php b/lib/load.php
index f97db4e38eb897..79d0d2a14a9857 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -25,6 +25,11 @@
/**
* End: Include for phase 2
*/
+
+ if ( ! class_exists( 'WP_REST_Block_Directory_Controller' ) ) {
+ require dirname( __FILE__ ) . '/class-wp-rest-block-directory-controller.php';
+ }
+
require dirname( __FILE__ ) . '/rest-api.php';
}
diff --git a/lib/rest-api.php b/lib/rest-api.php
index d9671557bfcb8b..0f7a1c59494199 100644
--- a/lib/rest-api.php
+++ b/lib/rest-api.php
@@ -78,6 +78,14 @@ function gutenberg_register_rest_widget_areas() {
$widget_areas_controller->register_routes();
}
add_action( 'rest_api_init', 'gutenberg_register_rest_widget_areas' );
+
/**
- * End: Include for phase 2
+ * Registers the block directory.
+ *
+ * @since 5.7.0
*/
+function gutenberg_register_rest_block_directory() {
+ $block_directory_controller = new WP_REST_Block_Directory_Controller();
+ $block_directory_controller->register_routes();
+}
+add_filter( 'rest_api_init', 'gutenberg_register_rest_block_directory' );
diff --git a/package-lock.json b/package-lock.json
index 501d8c5034ff11..440ac79e6d70ea 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4712,6 +4712,20 @@
"@babel/runtime": "^7.4.4"
}
},
+ "@wordpress/block-directory": {
+ "version": "file:packages/block-directory",
+ "requires": {
+ "@wordpress/api-fetch": "file:packages/api-fetch",
+ "@wordpress/block-editor": "file:packages/block-editor",
+ "@wordpress/blocks": "file:packages/blocks",
+ "@wordpress/components": "file:packages/components",
+ "@wordpress/compose": "file:packages/compose",
+ "@wordpress/data": "file:packages/data",
+ "@wordpress/element": "file:packages/element",
+ "@wordpress/i18n": "file:packages/i18n",
+ "lodash": "^4.17.14"
+ }
+ },
"@wordpress/block-editor": {
"version": "file:packages/block-editor",
"requires": {
@@ -5039,6 +5053,8 @@
"@babel/runtime": "^7.4.4",
"@wordpress/api-fetch": "file:packages/api-fetch",
"@wordpress/autop": "file:packages/autop",
+ "@wordpress/blob": "file:packages/blob",
+ "@wordpress/block-directory": "file:packages/block-directory",
"@wordpress/block-editor": "file:packages/block-editor",
"@wordpress/blocks": "file:packages/blocks",
"@wordpress/components": "file:packages/components",
diff --git a/package.json b/package.json
index d816fa8a0a5e1f..073feba117083c 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@wordpress/api-fetch": "file:packages/api-fetch",
"@wordpress/autop": "file:packages/autop",
"@wordpress/blob": "file:packages/blob",
+ "@wordpress/block-directory": "file:packages/block-directory",
"@wordpress/block-editor": "file:packages/block-editor",
"@wordpress/block-library": "file:packages/block-library",
"@wordpress/block-serialization-default-parser": "file:packages/block-serialization-default-parser",
diff --git a/packages/block-directory/.npmrc b/packages/block-directory/.npmrc
new file mode 100644
index 00000000000000..43c97e719a5a82
--- /dev/null
+++ b/packages/block-directory/.npmrc
@@ -0,0 +1 @@
+package-lock=false
diff --git a/packages/block-directory/CHANGELOG.md b/packages/block-directory/CHANGELOG.md
new file mode 100644
index 00000000000000..7586a4a9c8d2c3
--- /dev/null
+++ b/packages/block-directory/CHANGELOG.md
@@ -0,0 +1,3 @@
+## Master
+
+- Initial release.
diff --git a/packages/block-directory/README.md b/packages/block-directory/README.md
new file mode 100644
index 00000000000000..16ef9ac8bc8d30
--- /dev/null
+++ b/packages/block-directory/README.md
@@ -0,0 +1,17 @@
+# Block directory
+
+Package used to extend editor with block directory features to search and install blocks.
+
+> This package is meant to be used only with WordPress core. Feel free to use it in your own project but please keep in mind that it might never get fully documented.
+
+## Installation
+
+Install the module
+
+```bash
+npm install @wordpress/block-directory --save
+```
+
+_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._
+
+
diff --git a/packages/block-directory/package.json b/packages/block-directory/package.json
new file mode 100644
index 00000000000000..e16839c9ab31f3
--- /dev/null
+++ b/packages/block-directory/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@wordpress/block-directory",
+ "version": "1.0.0-alpha.0",
+ "description": "Extend editor with block directory features to search, download and install blocks.",
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "wordpress",
+ "block directory"
+ ],
+ "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/block-directory/README.md",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/WordPress/gutenberg.git",
+ "directory": "packages/block-directory"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/issues"
+ },
+ "main": "build/index.js",
+ "module": "build-module/index.js",
+ "dependencies": {
+ "@wordpress/api-fetch": "file:../api-fetch",
+ "@wordpress/block-editor": "file:../block-editor",
+ "@wordpress/blocks": "file:../blocks",
+ "@wordpress/components": "file:../components",
+ "@wordpress/compose": "file:../compose",
+ "@wordpress/data": "file:../data",
+ "@wordpress/element": "file:../element",
+ "@wordpress/i18n": "file:../i18n",
+ "lodash": "^4.17.14"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/block-directory/src/components/block-ratings/index.js b/packages/block-directory/src/components/block-ratings/index.js
new file mode 100644
index 00000000000000..ff4b8111fc7d84
--- /dev/null
+++ b/packages/block-directory/src/components/block-ratings/index.js
@@ -0,0 +1,26 @@
+/**
+ * WordPress dependencies
+ */
+import { _n, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import Stars from './stars';
+
+export const BlockRatings = ( { rating, ratingCount } ) => (
+
+
+
+ ({ ratingCount })
+
+
+);
+
+export default BlockRatings;
diff --git a/packages/block-directory/src/components/block-ratings/stars.js b/packages/block-directory/src/components/block-ratings/stars.js
new file mode 100644
index 00000000000000..9eb74e9c89bd89
--- /dev/null
+++ b/packages/block-directory/src/components/block-ratings/stars.js
@@ -0,0 +1,30 @@
+/**
+ * External dependencies
+ */
+import { times } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { Icon } from '@wordpress/components';
+
+function Stars( {
+ rating,
+} ) {
+ const stars = Math.round( rating / 0.5 ) * 0.5;
+
+ const fullStarCount = Math.floor( rating );
+ const halfStarCount = Math.ceil( rating - fullStarCount );
+ const emptyStarCount = 5 - ( fullStarCount + halfStarCount );
+
+ return (
+
+ { times( fullStarCount, ( i ) => ) }
+ { times( halfStarCount, ( i ) => ) }
+ { times( emptyStarCount, ( i ) => ) }
+
+ );
+}
+
+export default Stars;
diff --git a/packages/block-directory/src/components/block-ratings/style.scss b/packages/block-directory/src/components/block-ratings/style.scss
new file mode 100644
index 00000000000000..1244ff8a7bf8b3
--- /dev/null
+++ b/packages/block-directory/src/components/block-ratings/style.scss
@@ -0,0 +1,19 @@
+.block-directory-block-ratings {
+ display: flex;
+ > div {
+ line-height: 1;
+ display: flex;
+ }
+ .dashicons {
+ font-size: ms(-2);
+ width: 1.1em;
+ }
+ .block-directory-block-ratings__rating-count {
+ color: $dark-gray-400;
+ font-size: ms(-2);
+ }
+
+ [class*="dashicons-star-"] {
+ color: #ffb900;
+ }
+}
diff --git a/packages/block-directory/src/components/downloadable-block-author-info/index.js b/packages/block-directory/src/components/downloadable-block-author-info/index.js
new file mode 100644
index 00000000000000..0761aeae67cd9a
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-block-author-info/index.js
@@ -0,0 +1,20 @@
+/**
+ * WordPress dependencies
+ */
+import { Fragment } from '@wordpress/element';
+import { __, _n, sprintf } from '@wordpress/i18n';
+
+function DownloadableBlockAuthorInfo( { author, authorBlockCount, authorBlockRating } ) {
+ return (
+
+
+ { sprintf( __( 'Authored by %s' ), author ) }
+
+
+ { sprintf( _n( 'This author has %d block, with an average rating of %d.', 'This author has %d blocks, with an average rating of %d.', authorBlockCount, authorBlockRating ), authorBlockCount, authorBlockRating ) }
+
+
+ );
+}
+
+export default DownloadableBlockAuthorInfo;
diff --git a/packages/block-directory/src/components/downloadable-block-author-info/style.scss b/packages/block-directory/src/components/downloadable-block-author-info/style.scss
new file mode 100644
index 00000000000000..5692e5d66bfecb
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-block-author-info/style.scss
@@ -0,0 +1,9 @@
+.block-directory-downloadable-block-author-info__content {
+ color: $dark-gray-400;
+ margin-bottom: 4px;
+}
+
+.block-directory-downloadable-block-author-info__content-author {
+ margin-bottom: 4px;
+ font-size: 14px;
+}
diff --git a/packages/block-directory/src/components/downloadable-block-header/index.js b/packages/block-directory/src/components/downloadable-block-header/index.js
new file mode 100644
index 00000000000000..c660db683eb939
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-block-header/index.js
@@ -0,0 +1,43 @@
+/**
+ * WordPress dependencies
+ */
+import { Button } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { BlockIcon } from '@wordpress/block-editor';
+import BlockRatings from '../block-ratings';
+
+function DownloadableBlockHeader( { icon, title, rating, ratingCount, onClick } ) {
+ return (
+
+ {
+ icon.match( /\.(jpeg|jpg|gif|png)/ ) !== null ?
+
:
+
+
+
+ }
+
+
+
+ { title }
+
+
+
+
+
+ );
+}
+
+export default DownloadableBlockHeader;
diff --git a/packages/block-directory/src/components/downloadable-block-header/style.scss b/packages/block-directory/src/components/downloadable-block-header/style.scss
new file mode 100644
index 00000000000000..3855306bc7d7c4
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-block-header/style.scss
@@ -0,0 +1,29 @@
+.block-directory-downloadable-block-header__row {
+ display: flex;
+ flex-grow: 1;
+
+ .block-editor-block-icon {
+ width: 36px;
+ height: 36px;
+ font-size: 36px;
+ background-color: $light-gray-300;
+ }
+
+ img {
+ width: 36px;
+ height: 36px;
+ }
+
+ .block-directory-downloadable-block-header__column {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ .block-directory-downloadable-block-header__title {
+ font-weight: 600;
+ margin-left: 12px;
+ }
+ .block-directory-block-ratings {
+ margin-left: 12px;
+ }
+ }
+}
diff --git a/packages/block-directory/src/components/downloadable-block-info/index.js b/packages/block-directory/src/components/downloadable-block-info/index.js
new file mode 100644
index 00000000000000..9ccc8aa4e4e673
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-block-info/index.js
@@ -0,0 +1,26 @@
+/**
+ * WordPress dependencies
+ */
+import { Fragment } from '@wordpress/element';
+import { Icon } from '@wordpress/components';
+import { __, _n, sprintf } from '@wordpress/i18n';
+
+function DownloadableBlockInfo( { description, activeInstalls, humanizedUpdated } ) {
+ return (
+
+
+ { description }
+
+
+
+ { sprintf( _n( '%d active installation', '%d active installations', activeInstalls ), activeInstalls ) }
+
+
+ { humanizedUpdated }
+
+
+
+ );
+}
+
+export default DownloadableBlockInfo;
diff --git a/packages/block-directory/src/components/downloadable-block-info/style.scss b/packages/block-directory/src/components/downloadable-block-info/style.scss
new file mode 100644
index 00000000000000..8ae857cca22ce0
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-block-info/style.scss
@@ -0,0 +1,24 @@
+.block-directory-downloadable-block-info__content {
+ font-size: 14px;
+}
+
+.block-directory-downloadable-block-info__row {
+ display: flex;
+ justify-content: space-between;
+ color: $dark-gray-400;
+ margin-top: 8px;
+
+ .block-directory-downloadable-block-info__column {
+ display: flex;
+ align-items: flex-start;
+
+ .dashicon {
+ font-size: 16px;
+ margin-right: 4px;
+ }
+
+ .dashicons-chart-line {
+ font-weight: 900;
+ }
+ }
+}
diff --git a/packages/block-directory/src/components/downloadable-block-list-item/index.js b/packages/block-directory/src/components/downloadable-block-list-item/index.js
new file mode 100644
index 00000000000000..c5bd8b8f0244e0
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-block-list-item/index.js
@@ -0,0 +1,56 @@
+/**
+ * Internal dependencies
+ */
+import DownloadableBlockHeader from '../downloadable-block-header';
+import DownloadableBlockAuthorInfo from '../downloadable-block-author-info';
+import DownloadableBlockInfo from '../downloadable-block-info';
+
+function DownloadableBlockListItem( {
+ item,
+ onClick,
+} ) {
+ const {
+ icon,
+ title,
+ description,
+ rating,
+ activeInstalls,
+ ratingCount,
+ author,
+ humanizedUpdated,
+ authorBlockCount,
+ authorBlockRating,
+ } = item;
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default DownloadableBlockListItem;
diff --git a/packages/block-directory/src/components/downloadable-block-list-item/style.scss b/packages/block-directory/src/components/downloadable-block-list-item/style.scss
new file mode 100644
index 00000000000000..87eeafbde50600
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-block-list-item/style.scss
@@ -0,0 +1,48 @@
+.block-directory-downloadable-block-list-item {
+ width: 100%;
+ padding: 0;
+ margin: 0 0 12px;
+ display: flex;
+ flex-direction: row;
+ font-size: $default-font-size;
+ color: $dark-gray-700;
+ align-items: flex-start;
+ justify-content: center;
+ background: transparent;
+ word-break: break-word;
+ border-radius: $radius-round-rectangle;
+ border: $border-width solid $light-gray-500;
+ transition: all 0.05s ease-in-out;
+ @include reduce-motion("transition");
+ position: relative;
+ text-align: left;
+}
+
+.block-directory-downloadable-block-list-item__panel {
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+}
+
+.block-directory-downloadable-block-list-item__header {
+ display: flex;
+ flex-direction: column;
+ padding: 12px 12px 0;
+}
+
+.block-directory-downloadable-block-list-item__body {
+ display: flex;
+ flex-direction: column;
+ padding: 12px;
+}
+
+.block-directory-downloadable-block-list-item__footer {
+ display: flex;
+ flex-direction: column;
+ padding: 12px;
+ background-color: $light-gray-200;
+}
+
+.block-directory-downloadable-block-list-item__content {
+ color: $dark-gray-400;
+}
diff --git a/packages/block-directory/src/components/downloadable-blocks-list/index.js b/packages/block-directory/src/components/downloadable-blocks-list/index.js
new file mode 100644
index 00000000000000..769cb25ae85cbb
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-blocks-list/index.js
@@ -0,0 +1,115 @@
+/**
+ * External dependencies
+ */
+import { noop } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { getBlockMenuDefaultClassName, unregisterBlockType } from '@wordpress/blocks';
+import { withDispatch } from '@wordpress/data';
+import { compose } from '@wordpress/compose';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import DownloadableBlockListItem from '../downloadable-block-list-item';
+
+const DOWNLOAD_ERROR_NOTICE_ID = 'block-download-error';
+const INSTALL_ERROR_NOTICE_ID = 'block-install-error';
+
+function DownloadableBlocksList( { items, onHover = noop, children, downloadAndInstallBlock } ) {
+ return (
+ /*
+ * Disable reason: The `list` ARIA role is redundant but
+ * Safari+VoiceOver won't announce the list otherwise.
+ */
+ /* eslint-disable jsx-a11y/no-redundant-roles */
+
+ { items && items.map( ( item ) =>
+ {
+ downloadAndInstallBlock( item );
+ onHover( null );
+ } }
+ onFocus={ () => onHover( item ) }
+ onMouseEnter={ () => onHover( item ) }
+ onMouseLeave={ () => onHover( null ) }
+ onBlur={ () => onHover( null ) }
+ isDisabled={ item.isDisabled }
+ item={ item }
+ />
+ ) }
+ { children }
+
+ /* eslint-enable jsx-a11y/no-redundant-roles */
+ );
+}
+
+export default compose(
+ withDispatch( ( dispatch, props ) => {
+ const { installBlock, downloadBlock } = dispatch( 'core/block-directory' );
+ const { createErrorNotice, removeNotice } = dispatch( 'core/notices' );
+ const { removeBlocks } = dispatch( 'core/block-editor' );
+ const { onSelect } = props;
+
+ return {
+ downloadAndInstallBlock: ( item ) => {
+ const onDownloadError = () => {
+ createErrorNotice(
+ __( 'Block previews can\'t load.' ),
+ {
+ id: DOWNLOAD_ERROR_NOTICE_ID,
+ actions: [
+ {
+ label: __( 'Retry' ),
+ onClick: () => {
+ removeNotice( DOWNLOAD_ERROR_NOTICE_ID );
+ downloadBlock( item, onSuccess, onDownloadError );
+ },
+ },
+ ],
+ } );
+ };
+
+ const onSuccess = () => {
+ const createdBlock = onSelect( item );
+
+ const onInstallBlockError = () => {
+ createErrorNotice(
+ __( 'Block previews can\'t install.' ),
+ {
+ id: INSTALL_ERROR_NOTICE_ID,
+ actions: [
+ {
+ label: __( 'Retry' ),
+ onClick: () => {
+ removeNotice( INSTALL_ERROR_NOTICE_ID );
+ installBlock( item, noop, onInstallBlockError );
+ },
+ },
+ {
+ label: __( 'Remove' ),
+ onClick: () => {
+ removeNotice( INSTALL_ERROR_NOTICE_ID );
+ removeBlocks( createdBlock.clientId );
+ unregisterBlockType( item.name );
+ },
+ },
+ ],
+ }
+ );
+ };
+
+ installBlock( item, noop, onInstallBlockError );
+ };
+
+ downloadBlock( item, onSuccess, onDownloadError );
+ },
+ };
+ } ),
+)( DownloadableBlocksList );
diff --git a/packages/block-directory/src/components/downloadable-blocks-list/style.scss b/packages/block-directory/src/components/downloadable-blocks-list/style.scss
new file mode 100644
index 00000000000000..2324e9dda5fade
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-blocks-list/style.scss
@@ -0,0 +1,7 @@
+.block-directory-downloadable-blocks-list {
+ list-style: none;
+ padding: 2px 0;
+ overflow: hidden;
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/packages/block-directory/src/components/downloadable-blocks-panel/index.js b/packages/block-directory/src/components/downloadable-blocks-panel/index.js
new file mode 100644
index 00000000000000..87a39c64a74ec5
--- /dev/null
+++ b/packages/block-directory/src/components/downloadable-blocks-panel/index.js
@@ -0,0 +1,78 @@
+/**
+ * WordPress dependencies
+ */
+import { Fragment } from '@wordpress/element';
+import { compose } from '@wordpress/compose';
+import { withSelect } from '@wordpress/data';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { Spinner, withSpokenMessages } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import DownloadableBlocksList from '../downloadable-blocks-list';
+
+function DownloadableBlocksPanel( { downloadableItems, onSelect, onHover, hasPermission, isLoading, isWaiting, debouncedSpeak } ) {
+ if ( ! hasPermission ) {
+ debouncedSpeak( __( 'Please contact your site administrator to install new blocks.' ) );
+ return (
+
+ { __( 'No blocks found in your library.' ) }
+
+ { __( 'Please contact your site administrator to install new blocks.' ) }
+
+ );
+ }
+
+ if ( isLoading || isWaiting ) {
+ return (
+
+
+
+ );
+ }
+
+ if ( ! downloadableItems.length ) {
+ return (
+
+ { __( 'No blocks found in your library.' ) }
+
+ );
+ }
+
+ const resultsFoundMessage = sprintf(
+ _n( 'We did find %d block available for download.', 'We did find %d blocks available.', downloadableItems.length ),
+ downloadableItems.length
+ );
+
+ debouncedSpeak( resultsFoundMessage );
+ return (
+
+
+ { __( 'No blocks found in your library. We did find these blocks available for download:' ) }
+
+
+
+ );
+}
+
+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..a0dfb751d91891
--- /dev/null
+++ b/packages/block-directory/src/store/controls.js
@@ -0,0 +1,149 @@
+/**
+ * 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
+ * @param {Function} onLoad callback function on success.
+ * @param {Function} onError callback funciton on failure.
+ * @return {Object} control descriptor.
+ */
+export function* loadAssets( assets, onLoad, onError ) {
+ return {
+ type: 'LOAD_ASSETS',
+ assets,
+ onLoad,
+ onError,
+ };
+}
+
+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 ( typeof assets === 'object' && assets.constructor === Array ) {
+ 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..96500088af06b2
--- /dev/null
+++ b/packages/block-directory/src/store/index.js
@@ -0,0 +1,39 @@
+/**
+ * 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..626ab64bb0b413
--- /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..d66530a6705997 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 isMenuEmpty = 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,26 @@ export class InserterMenu extends Component {
) }
- { isEmpty( suggestedItems ) && isEmpty( reusableItems ) && isEmpty( itemsPerCategory ) && (
- { __( 'No blocks found.' ) }
- ) }
+ <__experimentalInserterMenuExtension.Slot
+ fillProps={ {
+ onSelect,
+ onHover: this.onHover,
+ filterValue: this.state.filterValue,
+ isMenuEmpty,
+ } }
+ >
+ { ( fills ) => {
+ if ( fills.length ) {
+ return fills;
+ }
+ if ( isMenuEmpty ) {
+ return (
+ { __( 'No blocks found.' ) }
+ );
+ }
+ return null;
+ } }
+
@@ -515,6 +534,7 @@ export default compose(
}
ownProps.onSelect();
+ return insertedBlock;
},
};
} ),
diff --git a/packages/block-editor/src/store/defaults.js b/packages/block-editor/src/store/defaults.js
index 2e7cb1f59cb78e..f7076f28c27570 100644
--- a/packages/block-editor/src/store/defaults.js
+++ b/packages/block-editor/src/store/defaults.js
@@ -31,6 +31,7 @@ export const PREFERENCES_DEFAULTS = {
* __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
+ * __experimentalBlockDirectory boolean Whether the user has enabled the Block Directory
*/
export const SETTINGS_DEFAULTS = {
alignWide: false,
@@ -150,5 +151,6 @@ export const SETTINGS_DEFAULTS = {
__experimentalCanUserUseUnfilteredHTML: false,
__experimentalEnableLegacyWidgetBlock: false,
__experimentalEnableMenuBlock: false,
+ __experimentalBlockDirectory: false,
};
diff --git a/packages/editor/package.json b/packages/editor/package.json
index 2e0959473b50be..257d235f6b89de 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -24,6 +24,8 @@
"@babel/runtime": "^7.4.4",
"@wordpress/api-fetch": "file:../api-fetch",
"@wordpress/autop": "file:../autop",
+ "@wordpress/blob": "file:../blob",
+ "@wordpress/block-directory": "file:../block-directory",
"@wordpress/block-editor": "file:../block-editor",
"@wordpress/blocks": "file:../blocks",
"@wordpress/components": "file:../components",
diff --git a/packages/editor/src/components/inserter-menu-downloadable-blocks-panel/index.js b/packages/editor/src/components/inserter-menu-downloadable-blocks-panel/index.js
new file mode 100644
index 00000000000000..a53de233c1124c
--- /dev/null
+++ b/packages/editor/src/components/inserter-menu-downloadable-blocks-panel/index.js
@@ -0,0 +1,44 @@
+/**
+ * External dependencies
+ */
+import { debounce } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { __experimentalInserterMenuExtension } from '@wordpress/block-editor';
+import { DownloadableBlocksPanel } from '@wordpress/block-directory';
+import { useState } from '@wordpress/element';
+
+function InserterMenuDownloadableBlocksPanel() {
+ const [ debouncedFilterValue, setFilterValue ] = useState( '' );
+
+ const debouncedSetFilterValue = debounce( setFilterValue, 400 );
+
+ return (
+ <__experimentalInserterMenuExtension>
+ {
+ ( { onSelect, onHover, filterValue, isMenuEmpty } ) => {
+ if ( ! isMenuEmpty ) {
+ return null;
+ }
+
+ if ( debouncedFilterValue !== filterValue ) {
+ debouncedSetFilterValue( filterValue );
+ }
+
+ return (
+
+ );
+ }
+ }
+
+ );
+}
+
+export default InserterMenuDownloadableBlocksPanel;
diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js
index 27d3187900204a..093c4be28f80c1 100644
--- a/packages/editor/src/components/provider/index.js
+++ b/packages/editor/src/components/provider/index.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { map, pick, defaultTo } from 'lodash';
+import { map, pick, defaultTo, differenceBy, isEqual, noop } from 'lodash';
import memize from 'memize';
/**
@@ -15,6 +15,7 @@ import { BlockEditorProvider, transformStyles } from '@wordpress/block-editor';
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url';
import { decodeEntities } from '@wordpress/html-entities';
+import { unregisterBlockType } from '@wordpress/blocks';
/**
* Internal dependencies
@@ -23,6 +24,7 @@ import withRegistryProvider from './with-registry-provider';
import { mediaUpload } from '../../utils';
import ReusableBlocksButtons from '../reusable-blocks-buttons';
import ConvertToGroupButtons from '../convert-to-group-buttons';
+import InserterMenuDownloadableBlocksPanel from '../inserter-menu-downloadable-blocks-panel';
const fetchLinkSuggestions = async ( search ) => {
const posts = await apiFetch( {
@@ -40,6 +42,8 @@ const fetchLinkSuggestions = async ( search ) => {
} ) );
};
+const UNINSTALL_ERROR_NOTICE_ID = 'block-uninstall-error';
+
class EditorProvider extends Component {
constructor( props ) {
super( ...arguments );
@@ -103,6 +107,7 @@ class EditorProvider extends Component {
'onUpdateDefaultBlockStyles',
'__experimentalEnableLegacyWidgetBlock',
'__experimentalEnableMenuBlock',
+ '__experimentalBlockDirectory',
'showInserterHelpPanel',
] ),
__experimentalReusableBlocks: reusableBlocks,
@@ -134,6 +139,17 @@ class EditorProvider extends Component {
if ( this.props.settings !== prevProps.settings ) {
this.props.updateEditorSettings( this.props.settings );
}
+ if ( ! isEqual( this.props.downloadableBlocksToUninstall, prevProps.downloadableBlocksToUninstall ) ) {
+ this.props.downloadableBlocksToUninstall.forEach( ( blockType ) => {
+ this.props.uninstallBlock( blockType, noop, () => {
+ this.props.createWarningNotice(
+ __( 'Block previews can\'t uninstall.' ), {
+ id: UNINSTALL_ERROR_NOTICE_ID,
+ } );
+ } );
+ unregisterBlockType( blockType.name );
+ } );
+ }
}
componentWillUnmount() {
@@ -161,7 +177,7 @@ class EditorProvider extends Component {
settings,
reusableBlocks,
hasUploadPermissions,
- canUserUseUnfilteredHTML
+ canUserUseUnfilteredHTML,
);
return (
@@ -175,6 +191,7 @@ class EditorProvider extends Component {
{ children }
+ { editorSettings.__experimentalBlockDirectory && }
);
}
@@ -190,6 +207,10 @@ export default compose( [
__experimentalGetReusableBlocks,
} = select( 'core/editor' );
const { canUser } = select( 'core' );
+ const { getInstalledBlockTypes } = select( 'core/block-directory' );
+ const { getBlocks } = select( 'core/block-editor' );
+
+ const downloadableBlocksToUninstall = differenceBy( getInstalledBlockTypes(), getBlocks(), 'name' );
return {
canUserUseUnfilteredHTML: canUserUseUnfilteredHTML(),
@@ -197,6 +218,7 @@ export default compose( [
blocks: getEditorBlocks(),
reusableBlocks: __experimentalGetReusableBlocks(),
hasUploadPermissions: defaultTo( canUser( 'create', 'media' ), true ),
+ downloadableBlocksToUninstall,
};
} ),
withDispatch( ( dispatch ) => {
@@ -208,6 +230,7 @@ export default compose( [
__experimentalTearDownEditor,
} = dispatch( 'core/editor' );
const { createWarningNotice } = dispatch( 'core/notices' );
+ const { uninstallBlock } = dispatch( 'core/block-directory' );
return {
setupEditor,
@@ -221,6 +244,7 @@ export default compose( [
} );
},
tearDownEditor: __experimentalTearDownEditor,
+ uninstallBlock,
};
} ),
] )( EditorProvider );