diff --git a/docs/manifest.json b/docs/manifest.json
index a99e8600b111d..cd91061dc4947 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -218,7 +218,7 @@
{
"title": "Articles",
"slug": "articles",
- "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/outreach/articles.md",
+ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/outreach/docs/articles.md",
"parent": "outreach"
},
{
@@ -431,6 +431,12 @@
"markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/library-export-default-webpack-plugin/README.md",
"parent": "packages"
},
+ {
+ "title": "@wordpress/list-reusable-blocks",
+ "slug": "packages-list-reusable-blocks",
+ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/list-reusable-blocks/README.md",
+ "parent": "packages"
+ },
{
"title": "@wordpress/npm-package-json-lint-config",
"slug": "packages-npm-package-json-lint-config",
@@ -857,4 +863,4 @@
"markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core-viewport.md",
"parent": "data"
}
-]
+]
\ No newline at end of file
diff --git a/edit-post/components/header/more-menu/index.js b/edit-post/components/header/more-menu/index.js
index 42ab8d1756e84..17ea090a1b710 100644
--- a/edit-post/components/header/more-menu/index.js
+++ b/edit-post/components/header/more-menu/index.js
@@ -2,7 +2,7 @@
* WordPress dependencies
*/
import { __, _x } from '@wordpress/i18n';
-import { IconButton, Dropdown, MenuGroup } from '@wordpress/components';
+import { IconButton, Dropdown, MenuGroup, MenuItem } from '@wordpress/components';
import { Fragment } from '@wordpress/element';
/**
@@ -37,6 +37,12 @@ const MoreMenu = () => (
label={ __( 'Tools' ) }
filterName="editPost.MoreMenu.tools"
>
+
diff --git a/gutenberg.php b/gutenberg.php
index 2e6893a23094d..390f4ed75b23d 100644
--- a/gutenberg.php
+++ b/gutenberg.php
@@ -260,6 +260,12 @@ function gutenberg_add_edit_link( $actions, $post ) {
if ( 'wp_block' === $post->post_type ) {
unset( $actions['edit'] );
unset( $actions['inline hide-if-no-js'] );
+ $actions['export'] = sprintf(
+ '%s',
+ $post->ID,
+ __( 'Export as JSON', 'gutenberg' ),
+ __( 'Export as JSON', 'gutenberg' )
+ );
return $actions;
}
diff --git a/lib/client-assets.php b/lib/client-assets.php
index ccb15197caada..9eddc4fabe0d6 100644
--- a/lib/client-assets.php
+++ b/lib/client-assets.php
@@ -632,6 +632,22 @@ function gutenberg_register_scripts_and_styles() {
true
);
+ wp_register_script(
+ 'wp-list-reusable-blocks',
+ gutenberg_url( 'build/list-reusable-blocks/index.js' ),
+ array(
+ 'lodash',
+ 'wp-api-fetch',
+ 'wp-components',
+ 'wp-compose',
+ 'wp-element',
+ 'wp-i18n',
+ 'wp-polyfill-ecmascript',
+ ),
+ filemtime( gutenberg_dir_path() . 'build/list-reusable-blocks/index.js' ),
+ true
+ );
+
// Editor Styles.
// This empty stylesheet is defined to ensure backwards compatibility.
wp_register_style( 'wp-blocks', false );
@@ -718,6 +734,14 @@ function gutenberg_register_scripts_and_styles() {
);
wp_style_add_data( 'wp-block-library-theme', 'rtl', 'replace' );
+ wp_register_style(
+ 'wp-list-reusable-blocks',
+ gutenberg_url( 'build/list-reusable-blocks/style.css' ),
+ array( 'wp-components' ),
+ filemtime( gutenberg_dir_path() . 'build/list-reusable-blocks/style.css' )
+ );
+ wp_style_add_data( 'wp-list-reusable-block', '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;
@@ -1513,3 +1537,17 @@ function gutenberg_editor_scripts_and_styles( $hook ) {
*/
do_action( 'enqueue_block_editor_assets' );
}
+
+/**
+ * Enqueue the reusable blocks listing page's script
+ *
+ * @param string $hook Screen name.
+ */
+function wp_load_list_reusable_blocks( $hook ) {
+ $is_reusable_blocks_list_page = 'edit.php' === $hook && isset( $_GET['post_type'] ) && 'wp_block' === $_GET['post_type'];
+ if ( $is_reusable_blocks_list_page ) {
+ wp_enqueue_script( 'wp-list-reusable-blocks' );
+ wp_enqueue_style( 'wp-list-reusable-blocks' );
+ }
+}
+add_action( 'admin_enqueue_scripts', 'wp_load_list_reusable_blocks' );
diff --git a/package-lock.json b/package-lock.json
index 012364cd9a18b..e2e98b24d9202 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20576,7 +20576,7 @@
},
"chalk": {
"version": "1.1.3",
- "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
"integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
"dev": true,
"requires": {
diff --git a/packages/components/src/menu-item/index.js b/packages/components/src/menu-item/index.js
index f0c2f3cf6046c..0904eb2463e85 100644
--- a/packages/components/src/menu-item/index.js
+++ b/packages/components/src/menu-item/index.js
@@ -21,7 +21,7 @@ import IconButton from '../icon-button';
*
* @return {WPElement} More menu item.
*/
-function MenuItem( { children, className, icon, onClick, shortcut, isSelected, role = 'menuitem', ...props } ) {
+function MenuItem( { children, className, icon, shortcut, isSelected, role = 'menuitem', ...props } ) {
className = classnames( 'components-menu-item__button', className, {
'has-icon': icon,
} );
@@ -39,7 +39,6 @@ function MenuItem( { children, className, icon, onClick, shortcut, isSelected, r
diff --git a/packages/list-reusable-blocks/package.json b/packages/list-reusable-blocks/package.json
new file mode 100644
index 0000000000000..ec0318fd7908a
--- /dev/null
+++ b/packages/list-reusable-blocks/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@wordpress/list-reusable-blocks",
+ "version": "1.0.0",
+ "description": "Adding Export/Import support to the reusable blocks listing.",
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "templates",
+ "reusable blocks"
+ ],
+ "private": true,
+ "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/list-reusable-blocks/README.md",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/WordPress/gutenberg.git"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/issues"
+ },
+ "main": "build/index.js",
+ "module": "build-module/index.js",
+ "dependencies": {
+ "@babel/runtime": "^7.0.0",
+ "@wordpress/api-fetch": "file:../api-fetch",
+ "@wordpress/components": "file:../components",
+ "@wordpress/compose": "file:../compose",
+ "@wordpress/element": "file:../element",
+ "@wordpress/i18n": "file:../i18n",
+ "lodash": "^4.17.10"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/list-reusable-blocks/src/components/import-dropdown/index.js b/packages/list-reusable-blocks/src/components/import-dropdown/index.js
new file mode 100644
index 0000000000000..18d1ffe7b242d
--- /dev/null
+++ b/packages/list-reusable-blocks/src/components/import-dropdown/index.js
@@ -0,0 +1,39 @@
+/**
+ * External dependencies
+ */
+import { flow } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Dropdown, Button } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import ImportForm from '../import-form';
+
+function ImportDropdown( { onUpload } ) {
+ return (
+ (
+
+ ) }
+ renderContent={ ( { onClose } ) => (
+
+ ) }
+ />
+ );
+}
+
+export default ImportDropdown;
diff --git a/packages/list-reusable-blocks/src/components/import-dropdown/style.scss b/packages/list-reusable-blocks/src/components/import-dropdown/style.scss
new file mode 100644
index 0000000000000..8c2dc9c94bd71
--- /dev/null
+++ b/packages/list-reusable-blocks/src/components/import-dropdown/style.scss
@@ -0,0 +1,3 @@
+.list-reusable-blocks-import-dropdown__content .components-popover__content {
+ padding: 10px;
+}
diff --git a/packages/list-reusable-blocks/src/components/import-form/index.js b/packages/list-reusable-blocks/src/components/import-form/index.js
new file mode 100644
index 0000000000000..b5169e376da4f
--- /dev/null
+++ b/packages/list-reusable-blocks/src/components/import-form/index.js
@@ -0,0 +1,113 @@
+/**
+ * WordPress dependencies
+ */
+import { Component } from '@wordpress/element';
+import { withInstanceId } from '@wordpress/compose';
+import { __ } from '@wordpress/i18n';
+import { Button, Notice } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import importReusableBlock from '../../utils/import';
+
+class ImportForm extends Component {
+ constructor() {
+ super( ...arguments );
+ this.state = {
+ isLoading: false,
+ error: null,
+ file: null,
+ };
+
+ this.isStillMounted = true;
+ this.onChangeFile = this.onChangeFile.bind( this );
+ this.onSubmit = this.onSubmit.bind( this );
+ }
+
+ componentWillUnmount() {
+ this.isStillMounted = false;
+ }
+
+ onChangeFile( event ) {
+ this.setState( { file: event.target.files[ 0 ] } );
+ }
+
+ onSubmit( event ) {
+ event.preventDefault();
+ const { file } = this.state;
+ const { onUpload } = this.props;
+ if ( ! file ) {
+ return;
+ }
+ this.setState( { isLoading: true } );
+ importReusableBlock( file )
+ .then( ( reusableBlock ) => {
+ if ( ! this.isStillMounted ) {
+ return;
+ }
+
+ this.setState( { isLoading: false } );
+ onUpload( reusableBlock );
+ } )
+ .catch( ( error ) => {
+ if ( ! this.isStillMounted ) {
+ return;
+ }
+
+ let uiMessage;
+ switch ( error.message ) {
+ case 'Invalid JSON file':
+ uiMessage = __( 'Invalid JSON file' );
+ break;
+ case 'Invalid Reusable Block JSON file':
+ uiMessage = __( 'Invalid Reusable Block JSON file' );
+ break;
+ default:
+ uiMessage = __( 'Unknow error' );
+ }
+
+ this.setState( { isLoading: false, error: uiMessage } );
+ } );
+ }
+
+ render() {
+ const { instanceId } = this.props;
+ const { file, isLoading, error } = this.state;
+ const inputId = 'list-reusable-blocks-import-form-' + instanceId;
+ return (
+
+ );
+ }
+}
+
+export default withInstanceId( ImportForm );
diff --git a/packages/list-reusable-blocks/src/components/import-form/style.scss b/packages/list-reusable-blocks/src/components/import-form/style.scss
new file mode 100644
index 0000000000000..184eaeb3a1f2d
--- /dev/null
+++ b/packages/list-reusable-blocks/src/components/import-form/style.scss
@@ -0,0 +1,13 @@
+.list-reusable-blocks-import-form__label {
+ display: block;
+ margin-bottom: 10px;
+}
+
+.list-reusable-blocks-import-form__button {
+ margin-top: 20px;
+ float: right;
+}
+
+.list-reusable-blocks-import-form .components-notice__content {
+ margin: 0;
+}
diff --git a/packages/list-reusable-blocks/src/index.js b/packages/list-reusable-blocks/src/index.js
new file mode 100644
index 0000000000000..76be468bdbd2c
--- /dev/null
+++ b/packages/list-reusable-blocks/src/index.js
@@ -0,0 +1,45 @@
+/**
+ * WordPress dependencies
+ */
+import { render } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import exportReusableBlock from './utils/export';
+import ImportDropdown from './components/import-dropdown';
+
+// Setup Export Links
+document.body.addEventListener( 'click', ( event ) => {
+ if ( ! event.target.classList.contains( 'wp-list-reusable-blocks__export' ) ) {
+ return;
+ }
+ event.preventDefault();
+ exportReusableBlock( event.target.dataset.id );
+} );
+
+// Setup Import Form
+document.addEventListener( 'DOMContentLoaded', () => {
+ const button = document.querySelector( '.page-title-action' );
+ if ( ! button ) {
+ return;
+ }
+
+ const showNotice = () => {
+ const notice = document.createElement( 'div' );
+ notice.className = 'notice notice-success is-dismissible';
+ notice.innerHTML = `${ __( 'Reusable block imported successfully!' ) }
`;
+
+ const headerEnd = document.querySelector( '.wp-header-end' );
+ if ( ! headerEnd ) {
+ return;
+ }
+ headerEnd.parentNode.insertBefore( notice, headerEnd );
+ };
+
+ const container = document.createElement( 'div' );
+ container.className = 'list-reusable-blocks__container';
+ button.parentNode.insertBefore( container, button );
+ render( , container );
+} );
diff --git a/packages/list-reusable-blocks/src/style.scss b/packages/list-reusable-blocks/src/style.scss
new file mode 100644
index 0000000000000..50445821dec62
--- /dev/null
+++ b/packages/list-reusable-blocks/src/style.scss
@@ -0,0 +1,9 @@
+@import "./components/import-dropdown/style.scss";
+@import "./components/import-form/style.scss";
+
+.list-reusable-blocks__container {
+ display: inline-flex;
+ padding: 9px 0 4px; // To match the H1
+ align-items: center;
+ vertical-align: top;
+}
diff --git a/packages/list-reusable-blocks/src/utils/export.js b/packages/list-reusable-blocks/src/utils/export.js
new file mode 100644
index 0000000000000..e3ca3b8b07788
--- /dev/null
+++ b/packages/list-reusable-blocks/src/utils/export.js
@@ -0,0 +1,33 @@
+/**
+ * External dependencies
+ */
+import { pick, kebabCase } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { download } from './file';
+
+/**
+ * Export a reusable block as a JSON file.
+ *
+ * @param {number} id
+ */
+async function exportReusableBlock( id ) {
+ const postType = await apiFetch( { path: `/wp/v2/types/wp_block` } );
+ const reusableBlock = await apiFetch( { path: `/wp/v2/${ postType.rest_base }/${ id }` } );
+ const fileContent = JSON.stringify( {
+ __file: 'wp_block',
+ ...pick( reusableBlock, [ 'title', 'content' ] ),
+ }, null, 2 );
+ const fileName = kebabCase( reusableBlock.title ) + '.json';
+
+ download( fileName, fileContent, 'application/json' );
+}
+
+export default exportReusableBlock;
diff --git a/packages/list-reusable-blocks/src/utils/file.js b/packages/list-reusable-blocks/src/utils/file.js
new file mode 100644
index 0000000000000..06875fd2009c4
--- /dev/null
+++ b/packages/list-reusable-blocks/src/utils/file.js
@@ -0,0 +1,30 @@
+/**
+ * Downloads a file.
+ *
+ * @param {string} fileName File Name.
+ * @param {string} content File Content.
+ * @param {string} contentType File mime type.
+ */
+export function download( fileName, content, contentType ) {
+ const a = document.createElement( 'a' );
+ const file = new window.Blob( [ content ], { type: contentType } );
+ a.href = URL.createObjectURL( file );
+ a.download = fileName;
+ a.click();
+}
+
+/**
+ * Reads the textual content of the given file.
+ *
+ * @param {File} file File.
+ * @return {Promise} Content of the file.
+ */
+export function readTextFile( file ) {
+ const reader = new window.FileReader();
+ return new Promise( ( resolve ) => {
+ reader.onload = function() {
+ resolve( reader.result );
+ };
+ reader.readAsText( file );
+ } );
+}
diff --git a/packages/list-reusable-blocks/src/utils/import.js b/packages/list-reusable-blocks/src/utils/import.js
new file mode 100644
index 0000000000000..6bf0204895284
--- /dev/null
+++ b/packages/list-reusable-blocks/src/utils/import.js
@@ -0,0 +1,52 @@
+/**
+ * External dependencies
+ */
+import { isString } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { readTextFile } from './file';
+
+/**
+ * Import a reusable block from a JSON file.
+ *
+ * @param {File} file File.
+ * @return {Promise} Promise returning the imported reusable block.
+ */
+async function importReusableBlock( file ) {
+ const fileContent = await readTextFile( file );
+ let parsedContent;
+ try {
+ parsedContent = JSON.parse( fileContent );
+ } catch ( e ) {
+ throw new Error( 'Invalid JSON file' );
+ }
+ if (
+ parsedContent.__file !== 'wp_block' ||
+ ! parsedContent.title ||
+ ! parsedContent.content ||
+ ! isString( parsedContent.title ) ||
+ ! isString( parsedContent.content )
+ ) {
+ throw new Error( 'Invalid Reusable Block JSON file' );
+ }
+ const postType = await apiFetch( { path: `/wp/v2/types/wp_block` } );
+ const reusableBlock = await apiFetch( {
+ path: `/wp/v2/${ postType.rest_base }`,
+ data: {
+ title: parsedContent.title,
+ content: parsedContent.content,
+ },
+ method: 'POST',
+ } );
+
+ return reusableBlock;
+}
+
+export default importReusableBlock;
diff --git a/test/e2e/assets/greeting-reusable-block.json b/test/e2e/assets/greeting-reusable-block.json
new file mode 100644
index 0000000000000..86427882bbb6a
--- /dev/null
+++ b/test/e2e/assets/greeting-reusable-block.json
@@ -0,0 +1,5 @@
+{
+ "__file": "wp_block",
+ "title": "Greeting",
+ "content": "\nHello there
\n"
+}
\ No newline at end of file
diff --git a/test/e2e/specs/manage-reusable-blocks.test.js b/test/e2e/specs/manage-reusable-blocks.test.js
new file mode 100644
index 0000000000000..922969f67155f
--- /dev/null
+++ b/test/e2e/specs/manage-reusable-blocks.test.js
@@ -0,0 +1,42 @@
+/**
+ * Node dependencies
+ */
+import path from 'path';
+
+/**
+ * Internal dependencies
+ */
+import { visitAdmin } from '../support/utils';
+
+describe( 'Managing reusable blocks', () => {
+ beforeAll( async () => {
+ await visitAdmin( 'edit.php', 'post_type=wp_block' );
+ } );
+
+ it( 'Should import reusable blocoks', async () => {
+ // Import Reusable block
+ await page.waitForSelector( '.list-reusable-blocks__container' );
+ const importButton = await page.$( '.list-reusable-blocks__container button' );
+ await importButton.click();
+
+ // Select the file to upload
+ const testReusableBlockFile = path.join( __dirname, '..', 'assets', 'greeting-reusable-block.json' );
+ const input = await page.$( '.list-reusable-blocks-import-form input' );
+ await input.uploadFile( testReusableBlockFile );
+
+ // Submit the form
+ const button = await page.$( '.list-reusable-blocks-import-form__button' );
+ await button.click();
+
+ // Wait for the success notice
+ await page.waitForSelector( '.notice-success' );
+ const noticeContent = await page.$eval( '.notice-success', ( element ) => element.textContent );
+ expect( noticeContent ).toEqual( 'Reusable block imported successfully!' );
+
+ // Refresh the page
+ await visitAdmin( 'edit.php', 'post_type=wp_block' );
+
+ // The reusable block has been imported
+ page.waitForXPath( 'div[@class="post_title"][contains(text(), "Greeting")]' );
+ } );
+} );
diff --git a/webpack.config.js b/webpack.config.js
index 2a926a952255a..fbd8a0feb0d45 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -103,6 +103,7 @@ const gutenbergPackages = [
'i18n',
'is-shallow-equal',
'keycodes',
+ 'list-reusable-blocks',
'nux',
'plugins',
'redux-routine',